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.
Related
I'm attempting to include a series of UITextFields, with corresponding UILabels, in a vertical UIStackView. I'm nesting each label/field pair in a horizontal UIStackView, and the width of the labels is hard-coded to the widest intrinsic width of all the labels, so that they line up nicely.
It very nearly works, but the horizontal stack views are not spaced evenly as expected (spacing = 5) and are bunched up and overlapping.
HOWVER, if I delete the line hFieldStack.alignment = .lastBaseline, then it all works perfectly.
I'm OK with deleting that line, as it looks fine. But I'm just curious as to why spacing in the outside stack view doesn't work when using lastBaseline alignment within the nested stack view.
stackView.axis = .vertical
stackView.spacing = 5
stackView.layoutMargins = UIEdgeInsets(top: 5, left: 0, bottom: 0, right: 0)
stackView.isLayoutMarginsRelativeArrangement = true
if let titleLabel = titleLabel {
stackView.addArrangedSubview(titleLabel)
}
if let messageLabel = messageLabel {
stackView.addArrangedSubview(messageLabel)
}
if textFields.count == fieldLabels.count {
for i in 0..<textFields.count {
fieldLabels[i].widthAnchor.constraint(equalToConstant: 80).isActive = true
let hFieldStack = UIStackView()
hFieldStack.translatesAutoresizingMaskIntoConstraints = false
hFieldStack.axis = .horizontal
hFieldStack.alignment = .lastBaseline
hFieldStack.spacing = 5
hFieldStack.layoutMargins = UIEdgeInsets(top: 0, left: 10, bottom: 0, right: 10)
hFieldStack.isLayoutMarginsRelativeArrangement = true
hFieldStack.addArrangedSubview(fieldLabels[i])
hFieldStack.addArrangedSubview(textFields[i])
stackView.addArrangedSubview(hFieldStack)
}
} else {
logger.error("Field count doesn't match label count")
}
Curious...
It appears that, when setting the horizontal stack view's Alignment to .lastBaseline (also happens with .firstBaseline), Auto-layout is using the text field's _UITextFieldCanvasView to calculate the layout.
So, with a rounded-rect border, the actual frame of the text field is taller than the _UITextFieldCanvasView.
We can confirm this by inspecting the view hierarchy:
The labels are nicely baseline aligned with the text rendered in the _UITextFieldCanvasView... but the frames are obviously extending outside the stack view's framing.
While the docs do list .lastBaseline as. valid Alignment constant, it's somewhat telling that, in Storyboard / Interface Builder, the only Alignment options are Fill, Leading, Center and Trailing.
I've come across several actual Bugs related to StackViews --- so I think I would (personally) add this one to the list.
I have the following UICollectionView:
It has vertical scrolling, 1 section and 31 items.
It has the basic setup and I am calculating itemSize to fit exactly 7 per row.
Currently it looks like this:
However, I would like to make an inset before first item, so that the layout is even and there are the same number of items in first and last row. This is static and will always contain 31 items, so I am basically trying to add left space/inset before first item, so that it looks like this:
I have tried using a custom UICollectionViewDelegateFlowLayout method:
collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, insetForSectionAt section: Int)
But since there is only 1 section with 31 rows, it insets all of the rows, not just the first.
I know I could probably add two more "blank" items, but I feel like there is a better solution I may not be aware of. Any ideas?
EDIT: I've tried Tarun's answer, but this doesn't work. Origin of first item changes, but the rest stays as is, therefore first overlaps the second and the rest remain as they were. Shifting them all doesn't work either. I ended up with:
You need to subclass UICollectionViewFlowLayout and that will provide you a chance to customize the frame for all items within the collectionView.
import UIKit
class LeftAlignCellCollectionFlowLayout: UICollectionViewFlowLayout {
override func layoutAttributesForElements(in rect: CGRect) -> [UICollectionViewLayoutAttributes]? {
guard let attributes = super.layoutAttributesForElements(in: rect) else { return nil }
guard let collectionView = self.collectionView else { return nil }
var newAttributes: [UICollectionViewLayoutAttributes] = []
let leftMargin = self.sectionInset.left
let layout = collectionView.collectionViewLayout
for attribute in attributes {
if let cellAttribute = layout.layoutAttributesForItem(at: attribute.indexPath) {
// Check for `indexPath.item == 0` & do what you want
// cellAttribute.frame.origin.x = 0 // 80
newAttributes.append(cellAttribute)
}
}
return newAttributes
}
}
Now you can use this custom layout class as your flow layout like following.
let flowLayout = LeftAlignCellCollectionFlowLayout()
collectionView.collectionViewLayout = flowLayout
Following Taran's suggestion, I've decided to use a custom UICollectionViewFlowLayout. Here is a generic answer that works for any number of items in the collectionView, as well as any inset value:
override func layoutAttributesForElements(in rect: CGRect) -> [UICollectionViewLayoutAttributes]? {
guard let collectionView = self.collectionView else { return nil }
guard let attributes = super.layoutAttributesForElements(in: rect) else { return nil }
var newAttributes: [UICollectionViewLayoutAttributes] = []
for attribute in attributes {
if let cellAttribute = collectionView.collectionViewLayout.layoutAttributesForItem(at: attribute.indexPath) {
let itemSize = cellAttribute.frame.size.width + self.minimumInteritemSpacing
let targetOriginX = cellAttribute.frame.origin.x + CGFloat(self.itemInset) * itemSize
if targetOriginX <= collectionView.bounds.size.width {
cellAttribute.frame.origin.x = targetOriginX
} else {
let shiftedPosition = lround(Double((targetOriginX / itemSize).truncatingRemainder(dividingBy: CGFloat(self.numberOfColumns))))
cellAttribute.frame.origin.x = itemSize * CGFloat(shiftedPosition)
cellAttribute.frame.origin.y += itemSize
}
newAttributes.append(cellAttribute)
}
}
return newAttributes
}
where:
self.itemInset is the value we want to inset from the left (2 for my initial question, but it can be any number from 0 to the number of columns-1)
self.numberOfColumns is - as the name suggests - number of columns in the collectionView. This pertains to the number of days in my example and would always be equal to 7, but one might want this to be a generic value for some other use case.
Just for the sake of the completeness, I provide a method that calculates a size for my callendar collection view, based on the number of columns (days):
private func collectionViewItemSize() -> CGSize {
let dimension = self.collectionView.frame.size.width / CGFloat(Constants.numberOfDaysInWeek) - Constants.minimumInteritemSpacing
return CGSize(width: dimension, height: dimension)
}
For me, Constants.numberOfDaysInWeek is naturally 7, and Constants.minimumInteritemSpacing is equal to 2, but those can be any numbers you desire.
My goal is to implement a collection that looks like so:
Instagram's discover tab
I can achieve a similar result using UICollectionViewCompositionalLayout but I am constrained to iOS versions lower than 13.0, which makes this a no go.
I have been testing UICollectionViewFlowLayout and I can achieve multiple columns using collectionView(_:layout:sizeForItemAt:), but I can't find a way to dynamically calculate the height of the cells, which can be either an image, a label, etc.
Thanks
You need to subclass UICollectionViewFlowLayout which will provide you an opportunity to all the modifications to cell frames and more.
import UIKit
class InstagramCollectionViewFlowLayout: UICollectionViewFlowLayout {
override func layoutAttributesForElements(in rect: CGRect) -> [UICollectionViewLayoutAttributes]? {
// These attributes have all the information about how
// collectionView's default layout will layout your cells
guard let attributes = super.layoutAttributesForElements(in: rect) else { return nil }
guard let collectionView = self.collectionView else { return nil }
var newAttributes: [UICollectionViewLayoutAttributes] = []
let layout = collectionView.collectionViewLayout
for attribute in attributes {
if let cellAttribute = layout.layoutAttributesForItem(at: attribute.indexPath) {
// Decide which indexPaths' cells should appear bigger
if attribute.indexPath.item % 2 == 0 {
// whatever modifications you want for big cells
cellAttribute.frame.size = CGSize(width: 200, height: 200)
}
else {
// whatever modifications you want for small cells
cellAttribute.frame.size = CGSize(width: 70, height: 70)
}
// Since you are modifying default layout and making some cells bigger
// You will need to accoun for extra height in your calculations
// I am not doing that here
newAttributes.append(cellAttribute)
}
}
return newAttributes
}
}
Usage
let igFlowLayout = InstagramCollectionViewFlowLayout()
collectionView.collectionViewLayout = igFlowLayout
Notes
This is just an idea of how you are supposed to create a subclass to customize cell frames. You will need to add a lot more to this according to your own implementation.
Subclassing UICollectionViewLayout also means that now you are fully responsible for telling collectionView how big it's collectionViewContentSize should be for all your data.
Reference
Customizing Collection View Layouts has all the information you need to get started on this. They explain how to achieve a similar layout in their example.
I have an array that count is between 1 to 3 varies.
I want to frame them in the middle of the screen with a certain distance
Also the width of the labels is fixed
let array = ["some1", "some2", "some3"]
func setLabel(){
var i = -1
for text in array{
i += 1
let label = UILabel()
label.fram = CGRect(x: screenWidth/2 - (CGFloat(i)*50) + 25, y: 100, width: 50 , height: 20)
label.text = text
addSubview(label)
}
}
You can use stackview as suggested by #matt. If you don't want to use stackview you can calculate the x position by array count and label index like this
let array = ["some1", "some2", "some3"]
func setLabel(){
var i:CGFloat = 0
for text in array{
i += 1
let label = UILabel()
label.backgroundColor = .red
label.frame = CGRect(x: (view.bounds.width/CGFloat(array.count+1))*i-25, y: 100, width: 50 , height: 20)
label.text = text
view.addSubview(label)
}
}
The really easy way to do this is to put the labels (one, two, or three of them) into a centered UIStackView. You would still need to know the desired fixed width of a label and apply a width constraint to each label, but everything else will just take care of itself.
Just as a demonstration, I used an arbitrary width of 100, with the text for all three labels being just "UILabel" as in your image. Here's what it looks like with one label:
Here's what it looks like with three labels:
Here's the code I used (sv is the stack view, which has already been configured in the storyboard; n is how many labels we want):
for _ in 1...n {
let lab = UILabel()
lab.text = "UILabel"
lab.textAlignment = .center
lab.translatesAutoresizingMaskIntoConstraints = false
lab.widthAnchor.constraint(equalToConstant: 100).isActive = true
sv.addArrangedSubview(lab)
}
So all you really need to do is get the right text from your array as we loop, and you're done. The point is that you don't need to think about frames at all! The UIStackView imposes the correct the frames for us.
NOTE Although I said above that the stack view was "configured in the storyboard", that has nothing to do with the answer. You can create and configure the stack view in code just as well, without changing anything about my answer. The point is that the stack view already knows how to receive an arbitrary number of views, space them out evenly, and center them as a whole. That is what it is for. So if, as your question implies, you can't manage the arithmetic to assign the views a frame yourself, why not let the stack view do it for you?
I have several labels inside an scrollview. Each label is an "page". But the offset isn't right. And I can't find the problem.
My code:
let quantity = 10;
var width : CGFloat = 0.0;
override func viewDidLoad() {
super.viewDidLoad()
width = self.scrView.frame.width;
for i in 0..<quantity {
let xPos = width * CGFloat(i);
let label = UILabel(frame: CGRect(x: xPos, y: 0, width: width, height: self.scrView.frame.height))
label.textAlignment = .center
label.font = UIFont(name: "Helvetica Neue", size: 50)
label.textColor = UIColor.white
label.text = "\(i + 1)"
label.backgroundColor = .randomColor()
scrView.addSubview(label);
}
scrView.contentSize.width = width * CGFloat(quantity + 1);
}
The pink color on the left side is from the previous "page".
And again, note the purple color on the left side from the previous (number 8) page.
The first page seems fine. However the higher the number the greater the offset. I want to cover the whole visible area for 1 label.
Does somebody knows an solution?
Your code works fine for me.
Note that the size of the scrollView can change after viewDidLoad as a result of Auto Layout. A better place to set up your scrollView would be in an override of viewDidLayoutSubviews. The size of the scrollView will be established by then. But be careful, because viewDidLayoutSubviews will run multiple times, so make sure you only set up your scrollView once.
This is not your bug, but your contentSize.width is too big. You don't need to add 1 to quantity since there are exactly quantity labels in your scrollView content area.
scrView.contentSize.width = width * CGFloat(quantity)
Also, Swift doesn't need the ; at the ends of lines, so delete those.