Goal:
UICollectionView with 'waterfall effect', entailing:
Multiple columns of cells (each cell contains only one stackview).
Each stackview contains n items, thus height of cells varies, but width is fixed.
Each cell in each column should butt up against one above it, with fixed padding, let's say CGFloat(50.0).
That produces a 'staggered' look across the columns, but no wasted space or 'empty slots'.
The Problem:
I've tried configuring the collection view using UICollectionViewFlowLayout, and alternatively, UICollectionViewCompositionalLayout without success
Best I've been able to achieve is cells aligned to top of each row, where each row has a uniform height (approx same height as tallest cell in that row). Meaning, next row starts below the bottom of the tallest cell in the previous row, leaving large gaps between shorter cells and cells placed cells in the next row (which is what I don't want).
Afterthoughts:
I suspect that's just how it is... that collection view (particularly compositional layout) doesn't know how to handle multiple columns with independent vertical layouts wherein all columns scroll vertically as one.
I'm not even sure if a custom layout could solve it, as I've never written one yet. Or if I'm just better off creating a big scroll view and layout the stackviews manually without a collection view, or multiple single-column collection views side by side? I don't understand enough of the tradeoffs and possibilities and hoping someone can steer me in the right direction.
Note, I did find this cool hack for making the content of cells top align for UICollectionViewFlow layout, and it works for what it is, but doesn't but doesn't give me the waterfall effect I'm looking e.g. doesn't tie multiple rows together for a seamless vertically sized cell height appearance.
UICollection View Flow Layout Vertical Align
class TopAlignedCollectionViewFlowLayout: UICollectionViewFlowLayout {
override func layoutAttributesForElements(in rect: CGRect) -> [UICollectionViewLayoutAttributes]? {
// This method is being requested to return the layout attributes for each cell in the rectangle.'
// where the attributes are frame, bounds, center, size, transform3D, transform, alpha, zIndix and isHidden
// It starts by getting a copy attributes the super class has configured each cell:
//
let attributes = super.layoutAttributesForElements(in: rect)?.map { $0.copy() } as? [UICollectionViewLayoutAttributes]
attributes?
// Then it's using the reduce() function to build a new dictionary of tupples consisting of:
// [cgfloat : (cgfloat, [attrs])], which is [ cell center.y : (cell origin.y, [all the other cells]) ]
///
.reduce([CGFloat: (CGFloat, [UICollectionViewLayoutAttributes])]()) { // for each cell, $0 = dict to build, $1 = next cell
guard $1.representedElementCategory == .cell else { return $0 } // only operate on cells (not decorations or supplemental views)
return $0.merging([ceil($1.center.y): ($1.frame.origin.y, [$1])]) { // center.y : origin.y, [merged attributes]
($0.0 < $1.0 ? $0.0 : $1.0, $0.1 + $1.1)
//
// if dict center.y < cell center.y
// (cell ctr.y, [cell/dict merged attributes]
// else
// (dict ctr.y, [cell/dict merged attributes]
}
} // returns dictionary of merged [cgfloat : (cgfloat, [attrs])]
.values.forEach { minY, line in // pull each item up to the top via diff btw center and top line
line.forEach {
$0.frame = $0.frame.offsetBy(
dx: 0,
dy: minY - $0.frame.origin.y
)
$0.frame = CGRectMake($0.frame.origin.x, $0.frame.origin.y + 200, $0.frame.size.width, 50) //$0.frame.size.height - (minY - $0.frame.origin.y))
}
}
return attributes
}
}
I've been wanting to implement this nice little UICollectionViewCell animation shown below that was on Dribble.
Do you think it's possible?
I looked around for any guides to get a head start on this but found nothing quite similar.
I have the idea that a custom flow layout is the way to go here. Would it be that I will have to make snapshots of each visible cell, add pan gestures to each cell and based on the movement detected through the gesture recogniser, capture visible cells and animate the snapshot images? Would appreciate any help to understand how I could implement this.
Thank you.
This is a pretty interesting challenge.
Instead of doing a custom layout, I would override scrollViewDidScroll, store the offset every time it's called, compare it with the last stored offset in order to get the velocity, and based off of that, apply a transform to all visibleCells in your collection view.
var lastOffsetX: CGFloat?
func scrollViewDidScroll(_ scrollView: UIScrollView) {
defer { lastOffsetX = scrollView.contentOffset.x }
guard let lastOffsetX = lastOffsetX else { return }
// You'll have to evaluate how large velocity gets to avoid the cells
// from stretching too much
let maxVelocity: CGFloat = 60
let maxStretch: CGFloat = 10
let velocity = min(scrollView.contentOffset.x - lastOffsetX, maxVelocity)
let stretch = velocity / maxVelocity * maxStretch
var cumulativeStretch: CGFloat = 0
collectionView.visibleCells.forEach { cell in
cumulativeStretch += stretch
cell.transform = CGAffineTransform(translateX: cumulativeStretch, y: 0)
}
}
I would start with something like this, and make lastOffsetX = nil when the scroll view stops scrolling (this exercise is left to the reader).
It will probably require some tweaking.
I've got a UIView in which I want to have a simple loader view by using a percentage to determine the width.
I've got the percentage code completed and I know it's working.
However I'm having trouble setting the view's width constraint. I find the width by getting the frame width and multiplying it by the percentage. I know it's getting the right width. But I can't seem to set the constraint from this function.
My code goes like this in my UIView subclass:
func initSubviews() {
// in here i do some other stuff and have an asyncronous call to an api
// so then I've got this code calling the next function
DispatchQueue.main.async {
self.setCompletionWidth(nextTimer: nextTimer!, oldDate: oldDate!)
}
}
func setCompletionWidth(nextTimer: Date, oldDate: Date) {
let date = Date()
// calculatePercent returns something like 0.49
let percent = calculatePercent(middleDate: date, endDate: nextTimer, originalDate: oldDate)
//this is returning a correct value
let width = (self.frame.width)*percent
// completionView is the view I'm trying to change the width of
self.completionView.widthAnchor.constraint(equalToConstant: width)
self.layoutSubviews()
}
What's happening is that the completionView isn't getting the right width.
I've also tried in the setCompletionWidth function
self.completionView.widthAnchor.constraint(equalToConstant: width)
containerView.layoutSubviews()
and
UIView.animate(withDuration: 0.2) {
self.completionView.widthAnchor.constraint(equalToConstant: width)
self.containerView.layoutSubviews()
//also tried self.layoutSubviews here
}
and
self.layoutIfNeeded()
self.completionView.widthAnchor.constraint(equalToConstant: width)
self.layoutIfNeeded()
I'm expecting the width of the completionView to be something like 150, but instead it's 350, which is the original width it had.
I think what's happening is the view isn't updating after me setting the constraint to a different value. However, I can't for the life of me get it to update. I'd love some help here.
You need .isActive = true
self.completionView.widthAnchor.constraint(equalToConstant: width).isActive = true
Also to change the width you need to create a width var like
var widthCon:NSLayoutConstraint!
widthCon = self.completionView.widthAnchor.constraint(equalToConstant: width)
widthCon.isActive = true
Then change the constant with
widthCon.constant = /// some value
self.superview!.layoutIfNeeded()
I have an NSScrollView that I am trying to populate with check boxes (NSButton / NSToggleButton). The number of checkboxes I add should be equal to a number of items in an array. This part is working.
The following code successfully adds the checkboxes to the scroll view, but they are all on top of each other. I am trying to add them, but offset from each other in a "list view" manner such as a tableview on iOS. Here's my code:
for calendar in self.allCalendars {
self.allCalendars.append(calendar as EKCalendar)
let checkbox = NSButton.init(checkboxWithTitle: calendar.title, target: self, action: #selector(self.checkboxDidChange))
checkbox.setButtonType(NSToggleButton)
self.checkboxArray.append(checkbox as NSButton)
self.addSubview(checkbox)
}
And here's a screenshot of the result:
Here is something that you could implement, it would change how you do your loop so that you have an index of each iteration, but it can help you calculate some sort of vertical spacing:
for i in 0 ..< allCalendars.count {
let margin: CGFloat = 5.0 // Or whatever you want the padding on the left to be
let vertSpacing: CGFloat = 10.0 // However much separation you want in between boxes
self.allCalendars.append(allCalendars[i] as EKCalendar)
let checkbox = NSButton.init(checkboxWithTitle: allCalendars[i].title, target: self, action: #selector(self.checkboxDidChange))
checkbox.setButtonType(NSToggleButton)
// Now you can set the frame of the checkbox within it's superview's coordinate system
// This calculates it based on the bounds.height, if you want to use a custom height, substitute that.
checkbox.frame = CGRect(x: margin, y: vertSpacing + (vertSpacing + checkbox.bounds.height)*CGFloat(i), width: checkbox.bounds.width, height: checkbox.bounds.height)
self.checkboxArray.append(checkbox as NSButton)
self.addSubview(checkbox)
}
Basically what that y-value vertSpacing + (vertSpacing + checkbox.bounds.height)*CGFloat(i) does is it starts the very first checkbox off with a y-pos of whatever you set your vertical spacing value to be, and then for every checkbox after, it will set it one vertical space value below the previous checkbox. This solution will assume that each checkbox is of the same height. You can set a custom height if you don't want to use the bounds.height value.
I want to use UICollectionView to display a horizontal scrollable list of thumbnails, however, I want this to be a single row. On another view, I'm displaying the thumbnails in a UICollectionView but this one scrolls vertically and it's in a single column.
Currently, the way I'm doing this is by changing the size of the collection view to only be big enough for one item so it autowraps and works, but now I'm dealing with variable sized items so it doesn't work anymore.
How do you make a UICollectionView display one row or column?
I am guessing you are using the built in Flow Layout.
However, for your case, you should make a custom UICollectionView Layout.
Make it subclass of UICollectionViewFlowLayout and add this code in the init method:
- (id)init {
if ((self = [super init])) {
self.scrollDirection = UICollectionViewScrollDirectionHorizontal;
self.minimumLineSpacing = 10000.0f;
}
return self;
}
The minimumLineSpacing is the key to making it have one line here.
I hope this helps!
I found that setting the minimumLineSpacing just made my scroll area really big and that setting the minimumInteritemSpacing=big accomplished keeping the items in one scrolling row or column, centered in the middle of the collectionview.
There isn't a real reason to subclass if you can trust the type cast.
((UICollectionViewFlowLayout *)self.collectionView.collectionViewLayout).minimumLineSpacing = 1000.0f;
If you have your collectionView loaded from a nib you could also play around with the parameters in the Attributes and Size Inspectors.
Same goes for scroll direction.
In iOS 13, thanks to UICollectionViewCompositionalLayout, this is trivial, because you can specify the number of cells per column in a horizontal layout, or the number of cells per row in a vertical layout. Here's a simple example for a single horizontally scrolling, vertically centered row of cells:
func layout() -> UICollectionViewCompositionalLayout {
let itemSize = NSCollectionLayoutSize(
widthDimension: .fractionalWidth(1),
heightDimension: .fractionalHeight(1))
let item = NSCollectionLayoutItem(layoutSize: itemSize)
let groupSize = NSCollectionLayoutSize(
widthDimension: .absolute(75),
heightDimension: .absolute(75))
let group = NSCollectionLayoutGroup.vertical(
layoutSize: groupSize, subitem: item, count: 1) // *
group.edgeSpacing = NSCollectionLayoutEdgeSpacing(
leading: nil, top: .flexible(0),
trailing: nil, bottom: .flexible(0))
let section = NSCollectionLayoutSection(group: group)
section.interGroupSpacing = 65
let config = UICollectionViewCompositionalLayoutConfiguration()
config.scrollDirection = .horizontal
let layout = UICollectionViewCompositionalLayout(
section: section, configuration:config)
return layout
}
Set your collection view's collectionViewLayout to the output of that function call and you're all set.
When you init your UICollectionView pass the following UICollectionViewFlowLayout in the init parameter.
Note: You do not need to create a subclass of UICollectionViewFlowLayout
var collectionViewFlowControl = UICollectionViewFlowLayout()
collectionViewFlowControl.scrollDirection = UICollectionViewScrollDirection.Horizontal
For 0 px Cell Spacing:
collectionViewFlowControl.minimumInteritemSpacing = 0;
collectionViewFlowControl.minimumLineSpacing = 0;
collectionView = UICollectionView(frame: CGRectMake(0, 0, self.view.frame.size.width, 120), collectionViewLayout: collectionViewFlowControl)
For Swift 3
let flowLayout = UICollectionViewFlowLayout()
flowLayout.itemSize = CGSize(width: UIScreen.main.bounds.width/2-10, height: 190)
flowLayout.sectionInset = UIEdgeInsetsMake(0, 5, 0, 5)
flowLayout.scrollDirection = UICollectionViewScrollDirection.horizontal
flowLayout.minimumInteritemSpacing = 0.0
self.gCollectionView.collectionViewLayout = flowLayout
I wanted one column, left aligned and the other answers didn't help me.
So here is my very simple solution for one column, left aligned:
class GCOneColumnLeftAlignedFlowLayout: UICollectionViewFlowLayout {
override func layoutAttributesForElements(in rect: CGRect) -> [UICollectionViewLayoutAttributes]? {
let attributes = super.layoutAttributesForElements(in: rect)
var y: CGFloat = sectionInset.top
attributes?.forEach { layoutAttribute in
layoutAttribute.frame.origin.x = sectionInset.left
layoutAttribute.frame.origin.y = y
y = y + layoutAttribute.frame.height + minimumLineSpacing
}
return attributes
}
}
you also should set something like
estimatedItemSize = CGSize(width: 120, height: 40)
Don't forget to remove the line
minimumLineSpacing = big number
From the other solutions.
Hope this is helpful because it's a completely different approach compared to the other answers.
This worked for me to get a single row of cells regardless of the height of the CollectionView. Set the Min spacing for cells to 1000
I think the documentation is the best :
minimumLineSpacingForSectionAt:
For a vertically scrolling grid, this value represents the minimum spacing between successive rows. For a horizontally scrolling grid, this value represents the minimum spacing between successive columns. This spacing is not applied to the space between the header and the first line or between the last line and the footer.
minimumInteritemSpacingForSectionAt:
For a vertically scrolling grid, this value represents the minimum spacing between items in the same row. For a horizontally scrolling grid, this value represents the minimum spacing between items in the same column. This spacing is used to compute how many items can fit in a single line, but after the number of items is determined, the actual spacing may possibly be adjusted upward.
So if your scrollDirection is horizontal, you need to set minimumInteritemSpacingForSectionAt to CGFloat.greatestFiniteMagnitude and if your scrollDirection is vertical, you need to set minimumLineSpacingForSectionAt to CGFloat.greatestFiniteMagnitude for getting one row collectionView
UICollectionViewFlowLayout *layout=[[UICollectionViewFlowLayout alloc] init];
[layout setScrollDirection:UICollectionViewScrollDirectionHorizontal];
layout.minimumInteritemSpacing = 0;
layout.minimumLineSpacing = 0;
blendEffectCollectionView=[[UICollectionView alloc] initWithFrame:CGRectMake(0, 95, 320, 60) collectionViewLayout:layout];
[blendEffectCollectionView setDataSource:self];
[blendEffectCollectionView setDelegate:self];
[blendEffectCollectionView registerClass:[BlendModeCell class] forCellWithReuseIdentifier:#"cellIdentifier"];
[blendEffectCollectionView setBackgroundColor:[UIColor clearColor]];
[bottomActivityView addSubview:blendEffectCollectionView];
2022, for anyone using TVOS
Thanks to the great answer from #matt we know how to do this now.
I found two insurmountable problems in TVOS project.
There's basically a bug where it inserts a large left content inset for some reason. The only way to get rid of it is to override safeAreaInsets.
Separate issue: Strangely if you just set the layout "inline" in the view controller as in the accepted answer from #matt, there's a bug or misbehaviour where the scroll does not start properly on the left, but in a random central position. (Even worse if you have many of the collection views on the screen, they all behave erraticlly.) Miraculously if you subclass instead, for some reason that fixes it (I guess due to the bringup order somehow).
Hence
class TightCollectionView: UICollectionView {
override var safeAreaInsets: UIEdgeInsets {
get {
return UIEdgeInsets(top: 0, left: 0, bottom: 0, right: 0)
}
set {
}
}
and then ...
func layout() -> UICollectionViewCompositionalLayout {
let itemSize = NSCollectionLayoutSize(widthDimension: .absolute(500), heightDimension: .absolute(300))
let item = NSCollectionLayoutItem(layoutSize: itemSize)
let groupSize = NSCollectionLayoutSize(widthDimension: .absolute(500), heightDimension: .absolute(300))
let group = NSCollectionLayoutGroup.vertical(layoutSize: groupSize, subitem: item, count: 1) // one count for single row
group.edgeSpacing = NSCollectionLayoutEdgeSpacing(leading: .fixed(0), top: .fixed(0), trailing: .fixed(0), bottom: .fixed(0))
let section = NSCollectionLayoutSection(group: group)
section.interGroupSpacing = 20
let config = UICollectionViewCompositionalLayoutConfiguration()
config.scrollDirection = .horizontal
let layout = UICollectionViewCompositionalLayout(section: section, configuration:config)
return layout
}
and thus ...
var bringup: Bool = true // I hate doing this but there's no really solid alternative in UIKit, thanks Apple
override func layoutSubviews() {
if bringup {
collectionViewLayout = layout()
bringup = false
}
super.layoutSubviews()
}
}
Footnote - note that code like this:
// section.contentInsetsReference = .none
// config.contentInsetsReference = .none
Seemingly does nothing at all on TVOS. I fiddled with it for hours.
Footnote
.absolute(500)
I could really only get solid results on all gen. of TVOS by just setting everything absolutely in the compositional layout. For now (2022!) it just seems too flakey otherwise.