I have tried this to get the index of a cell in the center of a collection view by using an extension of uicollectionview, but I always get nil instead of the index. How can I fix this?
extension UICollectionView {
var centerPoint : CGPoint {
get {
return CGPoint(x: self.center.x + self.contentOffset.x, y: self.center.y + self.contentOffset.y);
}
}
var centerCellIndexPath: NSIndexPath? {
if let centerIndexPath: NSIndexPath = self.indexPathForItemAtPoint(self.centerPoint) {
return centerIndexPath
}
return nil
}
}
then: in a UIViewController in a random method I have this:
if let centerCellIndexPath: NSIndexPath = collectionTemp!.centerCellIndexPath {
print(centerCellIndexPath)
} else {
println("nil")
}
the index path is always nil, and I don't get it why, because the cells display in order, everything is fine except this.
I've managed to solve my problem by using a custom layout that always keeps a cell in the center. Using this the indexPath in the center can never be nil.
class CenterFlowLayout: UICollectionViewFlowLayout {
override func targetContentOffsetForProposedContentOffset(proposedContentOffset: CGPoint, withScrollingVelocity velocity: CGPoint) -> CGPoint {
if let cv = self.collectionView {
let cvBounds = cv.bounds
let halfWidth = cvBounds.size.width * 0.5
let proposedContentOffsetCenterX = proposedContentOffset.x + halfWidth
if let attributesForVisibleCells = self.layoutAttributesForElementsInRect(cvBounds) as [UICollectionViewLayoutAttributes]! {
var candidateAttributes: UICollectionViewLayoutAttributes?
for attributes in attributesForVisibleCells {
// == Skip comparison with non-cell items (headers and footers) == //
if attributes.representedElementCategory != UICollectionElementCategory.Cell {
continue
}
if let candAttrs = candidateAttributes {
let a = attributes.center.x - proposedContentOffsetCenterX
let b = candAttrs.center.x - proposedContentOffsetCenterX
if fabsf(Float(a)) < fabsf(Float(b)) {
candidateAttributes = attributes
}
} else { // == First time in the loop == //
candidateAttributes = attributes
continue
}
}
return CGPoint(x : candidateAttributes!.center.x - halfWidth, y : proposedContentOffset.y)
}
}
// Fallback
return super.targetContentOffsetForProposedContentOffset(proposedContentOffset)
}
}
Add this as the subclass of the UICollectionFlowLayout.
Related
I have a UICollectionView with a custom collectionViewLayout like a carousel of cards.
It works fine when touching, but it doesn't align right when trying to change selected card from code.
The image shows the issue:
I first tried with scrollToItem, and some variations of setContentOffset, like:
collectionView.scrollToItem(at: indexPath, at: .left, animated: true)
collectionView.setContentOffset(CGPoint(x: (attri!.frame.origin.x - sectionLeftInset), y: 0), animated: true)
collectionView.setContentOffset(CGPoint(x: CGFloat((213 * currentPage)), y: 0), animated: true)
But it never fits.
Edit: Adding code of the UICollectionViewLayout
import UIKit
class CarouselLayout: UICollectionViewLayout {
// MARK: - Private Properties
private var cachedItemsAttributes: [IndexPath: UICollectionViewLayoutAttributes] = [:]
private let itemSize = CGSize(width: 213, height: 135)
private let spacing: CGFloat = 30
private let spacingWhenFocused: CGFloat = 60
// MARK: - Public Properties
override var collectionViewContentSize: CGSize {
let leftmostEdge = cachedItemsAttributes.values.map { $0.frame.minX }.min() ?? 0
let rightmostEdge = cachedItemsAttributes.values.map { $0.frame.maxX }.max() ?? 0
return CGSize(width: rightmostEdge - leftmostEdge, height: itemSize.height)
}
private var continuousFocusedIndex: CGFloat {
guard let collectionView = collectionView else { return 0 }
let offset = collectionView.bounds.width / 2 + collectionView.contentOffset.x - itemSize.width / 2
return offset / (itemSize.width + spacing)
}
// MARK: - Public Methods
override open func prepare() {
super.prepare()
guard let collectionView = self.collectionView else { return }
updateInsets()
guard cachedItemsAttributes.isEmpty else { return }
collectionView.decelerationRate = UIScrollView.DecelerationRate.fast
let itemsCount = collectionView.numberOfItems(inSection: 0)
for item in 0..<itemsCount {
let indexPath = IndexPath(item: item, section: 0)
cachedItemsAttributes[indexPath] = createAttributesForItem(at: indexPath)
}
}
override func layoutAttributesForElements(in rect: CGRect) -> [UICollectionViewLayoutAttributes]? {
return cachedItemsAttributes
.map { $0.value }
.filter { $0.frame.intersects(rect) }
.map { self.shiftedAttributes(from: $0) }
}
override func targetContentOffset(forProposedContentOffset proposedContentOffset: CGPoint, withScrollingVelocity velocity: CGPoint) -> CGPoint {
guard let collectionView = collectionView else { return super.targetContentOffset(forProposedContentOffset: proposedContentOffset) }
let collectionViewMidX: CGFloat = collectionView.bounds.size.width / 2
guard let closestAttribute = findClosestAttributes(toXPosition: proposedContentOffset.x + collectionViewMidX) else { return super.targetContentOffset(forProposedContentOffset: proposedContentOffset) }
return CGPoint(x: closestAttribute.center.x - collectionViewMidX, y: proposedContentOffset.y)
}
// MARK: - Invalidate layout
override func shouldInvalidateLayout(forBoundsChange newBounds: CGRect) -> Bool {
if newBounds.size != collectionView?.bounds.size { cachedItemsAttributes.removeAll() }
return true
}
override func invalidateLayout(with context: UICollectionViewLayoutInvalidationContext) {
if context.invalidateDataSourceCounts { cachedItemsAttributes.removeAll() }
super.invalidateLayout(with: context)
}
// MARK: - Items
override func layoutAttributesForItem(at indexPath: IndexPath) -> UICollectionViewLayoutAttributes? {
guard let attributes = cachedItemsAttributes[indexPath] else { fatalError("No attributes cached") }
return shiftedAttributes(from: attributes)
}
private func createAttributesForItem(at indexPath: IndexPath) -> UICollectionViewLayoutAttributes? {
let attributes = UICollectionViewLayoutAttributes(forCellWith: indexPath)
guard let collectionView = collectionView else { return nil }
attributes.frame.size = itemSize
attributes.frame.origin.y = (collectionView.bounds.height - itemSize.height) / 2
attributes.frame.origin.x = CGFloat(indexPath.item) * (itemSize.width + spacing)
return attributes
}
private func shiftedAttributes(from attributes: UICollectionViewLayoutAttributes) -> UICollectionViewLayoutAttributes {
guard let attributes = attributes.copy() as? UICollectionViewLayoutAttributes else { fatalError("Couldn't copy attributes") }
let roundedFocusedIndex = round(continuousFocusedIndex)
guard attributes.indexPath.item != Int(roundedFocusedIndex) else { return attributes }
let shiftArea = (roundedFocusedIndex - 0.5)...(roundedFocusedIndex + 0.5)
let distanceToClosestIdentityPoint = min(abs(continuousFocusedIndex - shiftArea.lowerBound), abs(continuousFocusedIndex - shiftArea.upperBound))
let normalizedShiftFactor = distanceToClosestIdentityPoint * 2
let translation = (spacingWhenFocused - spacing) * normalizedShiftFactor
let translationDirection: CGFloat = attributes.indexPath.item < Int(roundedFocusedIndex) ? -1 : 1
attributes.transform = CGAffineTransform(translationX: translationDirection * translation, y: 0)
return attributes
}
// MARK: - Private Methods
private func findClosestAttributes(toXPosition xPosition: CGFloat) -> UICollectionViewLayoutAttributes? {
guard let collectionView = collectionView else { return nil }
let searchRect = CGRect(
x: xPosition - collectionView.bounds.width, y: collectionView.bounds.minY,
width: collectionView.bounds.width * 2, height: collectionView.bounds.height
)
return layoutAttributesForElements(in: searchRect)?.min(by: { abs($0.center.x - xPosition) < abs($1.center.x - xPosition) })
}
private func updateInsets() {
guard let collectionView = collectionView else { return }
collectionView.contentInset.left = (collectionView.bounds.size.width - itemSize.width) / 2
collectionView.contentInset.right = (collectionView.bounds.size.width - itemSize.width) / 2
}
}
I'm trying to centerelize my cells on horizontal scroll. I've written one method, but it works only when I scroll to right, on scroll on left it just scrolls, without stopping on the cell's center.
Can anyone help me to define this bug, please?
class CenterCellCollectionViewFlowLayout: UICollectionViewFlowLayout {
override func targetContentOffsetForProposedContentOffset(proposedContentOffset: CGPoint, withScrollingVelocity velocity: CGPoint) -> CGPoint {
if let cv = self.collectionView {
let cvBounds = cv.bounds
let halfWidth = cvBounds.size.width * 0.5;
let proposedContentOffsetCenterX = proposedContentOffset.x + halfWidth;
if let attributesForVisibleCells = self.layoutAttributesForElementsInRect(cvBounds) {
var candidateAttributes: UICollectionViewLayoutAttributes?
for attributes in attributesForVisibleCells {
// == Skip comparison with non-cell items (headers and footers) == //
if attributes.representedElementCategory != UICollectionElementCategory.Cell {
continue
}
if (attributes.center.x == 0) || (attributes.center.x > (cv.contentOffset.x + halfWidth) && velocity.x < 0) {
continue
}
// == First time in the loop == //
guard let candAttrs = candidateAttributes else {
candidateAttributes = attributes
continue
}
let a = attributes.center.x - proposedContentOffsetCenterX
let b = candAttrs.center.x - proposedContentOffsetCenterX
if fabsf(Float(a)) < fabsf(Float(b)) {
candidateAttributes = attributes;
}
}
if(proposedContentOffset.x == -(cv.contentInset.left)) {
return proposedContentOffset
}
return CGPoint(x: floor(candidateAttributes!.center.x - halfWidth), y: proposedContentOffset.y)
}
} else {
print("else")
}
// fallback
return super.targetContentOffsetForProposedContentOffset(proposedContentOffset)
}
}
And in my UIViewController:
override func viewDidLayoutSubviews() {
super.viewDidLayoutSubviews()
var insets = self.collectionView.contentInset
let value = ((self.view.frame.size.width - ((CGRectGetWidth(collectionView.frame) - 35))) * 0.5)
insets.left = value
insets.right = value
self.collectionView.contentInset = insets
self.collectionView.decelerationRate = UIScrollViewDecelerationRateNormal
}
If you have any question - please ask me
I actually just did a slightly different implementation for another thread, but adjusted it to work for this questions. Try out the solution below :)
/**
* Custom FlowLayout
* Tracks the currently visible index and updates the proposed content offset
*/
class CustomCollectionViewFlowLayout: UICollectionViewFlowLayout {
// Tracks the currently visible index
private var visibleIndex : Int = 0
// The width offset threshold percentage from 0 - 1
let thresholdOffsetPrecentage : CGFloat = 0.5
// This is the flick velocity threshold
let velocityThreshold : CGFloat = 0.4
override init() {
super.init()
self.minimumInteritemSpacing = 0.0
self.minimumLineSpacing = 0.0
self.scrollDirection = .Horizontal
}
required init?(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func targetContentOffsetForProposedContentOffset(proposedContentOffset: CGPoint, withScrollingVelocity velocity: CGPoint) -> CGPoint {
let leftThreshold = CGFloat(collectionView!.bounds.size.width) * ((CGFloat(visibleIndex) - 0.5))
let rightThreshold = CGFloat(collectionView!.bounds.size.width) * ((CGFloat(visibleIndex) + 0.5))
let currentHorizontalOffset = collectionView!.contentOffset.x
// If you either traverse far enough in either direction,
// or flicked the scrollview over the horizontal velocity in either direction,
// adjust the visible index accordingly
if currentHorizontalOffset < leftThreshold || velocity.x < -velocityThreshold {
visibleIndex = max(0 , (visibleIndex - 1))
} else if currentHorizontalOffset > rightThreshold || velocity.x > velocityThreshold {
visibleIndex += 1
}
var _proposedContentOffset = proposedContentOffset
_proposedContentOffset.x = CGFloat(collectionView!.bounds.width) * CGFloat(visibleIndex)
return _proposedContentOffset
}
}
I am trying to convert this code in swift.
- (CGPoint)targetContentOffsetForProposedContentOffset:(CGPoint)proposedContentOffset withScrollingVelocity:(CGPoint)velocity
{
CGSize collectionViewSize = self.collectionView.bounds.size;
CGFloat proposedContentOffsetCenterX = proposedContentOffset.x + self.collectionView.bounds.size.width * 0.5f;
CGRect proposedRect = self.collectionView.bounds;
// Comment out if you want the collectionview simply stop at the center of an item while scrolling freely
// proposedRect = CGRectMake(proposedContentOffset.x, 0.0, collectionViewSize.width, collectionViewSize.height);
UICollectionViewLayoutAttributes* candidateAttributes;
for (UICollectionViewLayoutAttributes* attributes in [self layoutAttributesForElementsInRect:proposedRect])
{
// == Skip comparison with non-cell items (headers and footers) == //
if (attributes.representedElementCategory != UICollectionElementCategoryCell)
{
continue;
}
// == First time in the loop == //
if(!candidateAttributes)
{
candidateAttributes = attributes;
continue;
}
if (fabsf(attributes.center.x - proposedContentOffsetCenterX) < fabsf(candidateAttributes.center.x - proposedContentOffsetCenterX))
{
candidateAttributes = attributes;
}
}
return CGPointMake(candidateAttributes.center.x - self.collectionView.bounds.size.width * 0.5f, proposedContentOffset.y);
}
This is what I have done so far
func targetContentOffsetForProposedContentOffset(proposedContentOffset:CGPoint, velocity: CGPoint) -> CGPoint
{
let collectionViewSize:CGSize = self.collectionView.bounds.size
let proposedContentOffsetCenterX:CGFloat = proposedContentOffset.x + self.collectionView.bounds.size.width * 0.5
let proposedRect:CGRect = self.collectionView.bounds
//let candidateAttributes:UICollectionViewLayoutAttributes
var candidateAttributes:[UICollectionViewLayoutAttributes]
/* get stuck here in for loop*/
for (attributes: [UICollectionViewLayoutAttributes] in layoutAttributesForElementsInRect:proposedRect)
{
}
The issue is layoutAttributesForElementsInRect is a function and proposedRect is the argument of that function. You are calling that function wrongly in your code.
You need to change:
for (attributes: [UICollectionViewLayoutAttributes] in layoutAttributesForElementsInRect:proposedRect)
to
for attributes in layoutAttributesForElementsInRect(proposedRect) as! [UICollectionViewLayoutAttributes]
This solution may help you.
for attributes: UICollectionViewLayoutAttributes in self.layoutAttributesForElementsInRect(proposedRect)
{
// ADD YOUR CODE HERE
}
override func targetContentOffset(forProposedContentOffset proposedContentOffset: CGPoint, withScrollingVelocity velocity: CGPoint) -> CGPoint {
guard let collectionView = collectionView else {
return .zero
}
let collectionViewSize = collectionView.bounds.size
let proposedContentOffsetCenterX = proposedContentOffset.x + collectionViewSize.width / 2
let proposedRect = CGRect(x: proposedContentOffset.x, y: 0, width: collectionViewSize.width, height: collectionViewSize.height)
var candidateAttributes: UICollectionViewLayoutAttributes?
guard let layoutAttributes = layoutAttributesForElements(in: proposedRect) else {
return .zero
}
for attributes in layoutAttributes {
if attributes.representedElementCategory != UICollectionView.ElementCategory.cell {
continue
}
if candidateAttributes == nil {
candidateAttributes = attributes
continue
}
guard let unwrappedCandidateAttributes = candidateAttributes else {
return .zero
}
if abs(attributes.center.x - proposedContentOffsetCenterX) < abs(unwrappedCandidateAttributes.center.x - proposedContentOffsetCenterX) {
candidateAttributes = attributes
}
}
guard let unwrappedCandidateAttributes = candidateAttributes else {
return .zero
}
var newProposedContentOffset = proposedContentOffset
newProposedContentOffset.x = unwrappedCandidateAttributes.center.x - collectionView.bounds.size.width / 2
let offset = proposedContentOffset.x - collectionView.contentOffset.x
if (velocity.x < 0 && offset > 0) || (velocity.x > 0 && offset < 0) {
let pageWidth = itemSize.width + minimumLineSpacing
newProposedContentOffset.x += velocity.x > 0 ? pageWidth : -pageWidth;
}
return newProposedContentOffset
}
By default, when you are using the flow layout in a collection view, cells are centered vertically. Is there a way to change this alignment ?
Swift 4 with functional oriented approach:
class TopAlignedCollectionViewFlowLayout: UICollectionViewFlowLayout {
override func layoutAttributesForElements(in rect: CGRect) -> [UICollectionViewLayoutAttributes]? {
let attributes = super.layoutAttributesForElements(in: rect)?
.map { $0.copy() } as? [UICollectionViewLayoutAttributes]
attributes?
.reduce([CGFloat: (CGFloat, [UICollectionViewLayoutAttributes])]()) {
guard $1.representedElementCategory == .cell else { return $0 }
return $0.merging([ceil($1.center.y): ($1.frame.origin.y, [$1])]) {
($0.0 < $1.0 ? $0.0 : $1.0, $0.1 + $1.1)
}
}
.values.forEach { minY, line in
line.forEach {
$0.frame = $0.frame.offsetBy(
dx: 0,
dy: minY - $0.frame.origin.y
)
}
}
return attributes
}
}
following code worked for me
#interface TopAlignedCollectionViewFlowLayout : UICollectionViewFlowLayout
- (void)alignToTopForSameLineElements:(NSArray *)sameLineElements;
#end
#implementation TopAlignedCollectionViewFlowLayout
- (NSArray *)layoutAttributesForElementsInRect:(CGRect)rect;
{
NSArray *attrs = [super layoutAttributesForElementsInRect:rect];
CGFloat baseline = -2;
NSMutableArray *sameLineElements = [NSMutableArray array];
for (UICollectionViewLayoutAttributes *element in attrs) {
if (element.representedElementCategory == UICollectionElementCategoryCell) {
CGRect frame = element.frame;
CGFloat centerY = CGRectGetMidY(frame);
if (ABS(centerY - baseline) > 1) {
baseline = centerY;
[self alignToTopForSameLineElements:sameLineElements];
[sameLineElements removeAllObjects];
}
[sameLineElements addObject:element];
}
}
[self alignToTopForSameLineElements:sameLineElements];//align one more time for the last line
return attrs;
}
- (void)alignToTopForSameLineElements:(NSArray *)sameLineElements
{
if (sameLineElements.count == 0) {
return;
}
NSArray *sorted = [sameLineElements sortedArrayUsingComparator:^NSComparisonResult(UICollectionViewLayoutAttributes *obj1, UICollectionViewLayoutAttributes *obj2) {
CGFloat height1 = obj1.frame.size.height;
CGFloat height2 = obj2.frame.size.height;
CGFloat delta = height1 - height2;
return delta == 0. ? NSOrderedSame : ABS(delta)/delta;
}];
UICollectionViewLayoutAttributes *tallest = [sorted lastObject];
[sameLineElements enumerateObjectsUsingBlock:^(UICollectionViewLayoutAttributes *obj, NSUInteger idx, BOOL *stop) {
obj.frame = CGRectOffset(obj.frame, 0, tallest.frame.origin.y - obj.frame.origin.y);
}];
}
#end
#DongXu: Your solution worked for me too. Here is the SWIFT version if it:
class TopAlignedCollectionViewFlowLayout: UICollectionViewFlowLayout
{
override func layoutAttributesForElementsInRect(rect: CGRect) -> [UICollectionViewLayoutAttributes]?
{
if let attrs = super.layoutAttributesForElementsInRect(rect)
{
var baseline: CGFloat = -2
var sameLineElements = [UICollectionViewLayoutAttributes]()
for element in attrs
{
if element.representedElementCategory == .Cell
{
let frame = element.frame
let centerY = CGRectGetMidY(frame)
if abs(centerY - baseline) > 1
{
baseline = centerY
TopAlignedCollectionViewFlowLayout.alignToTopForSameLineElements(sameLineElements)
sameLineElements.removeAll()
}
sameLineElements.append(element)
}
}
TopAlignedCollectionViewFlowLayout.alignToTopForSameLineElements(sameLineElements) // align one more time for the last line
return attrs
}
return nil
}
private class func alignToTopForSameLineElements(sameLineElements: [UICollectionViewLayoutAttributes])
{
if sameLineElements.count < 1
{
return
}
let sorted = sameLineElements.sort { (obj1: UICollectionViewLayoutAttributes, obj2: UICollectionViewLayoutAttributes) -> Bool in
let height1 = obj1.frame.size.height
let height2 = obj2.frame.size.height
let delta = height1 - height2
return delta <= 0
}
if let tallest = sorted.last
{
for obj in sameLineElements
{
obj.frame = CGRectOffset(obj.frame, 0, tallest.frame.origin.y - obj.frame.origin.y)
}
}
}
}
Swift 3 Version in case someone just wants to Copy & Paste:
class TopAlignedCollectionViewFlowLayout: UICollectionViewFlowLayout {
override func layoutAttributesForElements(in rect: CGRect) -> [UICollectionViewLayoutAttributes]? {
if let attrs = super.layoutAttributesForElements(in: rect) {
var baseline: CGFloat = -2
var sameLineElements = [UICollectionViewLayoutAttributes]()
for element in attrs {
if element.representedElementCategory == .cell {
let frame = element.frame
let centerY = frame.midY
if abs(centerY - baseline) > 1 {
baseline = centerY
alignToTopForSameLineElements(sameLineElements: sameLineElements)
sameLineElements.removeAll()
}
sameLineElements.append(element)
}
}
alignToTopForSameLineElements(sameLineElements: sameLineElements) // align one more time for the last line
return attrs
}
return nil
}
private func alignToTopForSameLineElements(sameLineElements: [UICollectionViewLayoutAttributes]) {
if sameLineElements.count < 1 { return }
let sorted = sameLineElements.sorted { (obj1: UICollectionViewLayoutAttributes, obj2: UICollectionViewLayoutAttributes) -> Bool in
let height1 = obj1.frame.size.height
let height2 = obj2.frame.size.height
let delta = height1 - height2
return delta <= 0
}
if let tallest = sorted.last {
for obj in sameLineElements {
obj.frame = obj.frame.offsetBy(dx: 0, dy: tallest.frame.origin.y - obj.frame.origin.y)
}
}
}
}
The awesome top voted answer from Fabio Felici needs updating for iOS 15 and 16. On iOS 16 it will crash almost every time you scroll.
Here's a more thorough solution that implements layoutAttributesForItem to ensure that attributes are the same for all execution paths (to avoid a looping crash).
open class TopAlignedCollectionViewFlowLayout: UICollectionViewFlowLayout {
open override func layoutAttributesForItem(at indexPath: IndexPath) -> UICollectionViewLayoutAttributes? {
guard let layoutAttributes = super.layoutAttributesForItem(at: indexPath)?.copy() as? UICollectionViewLayoutAttributes else { return nil }
guard layoutAttributes.representedElementCategory == .cell else { return layoutAttributes }
func layoutAttributesForRow() -> [UICollectionViewLayoutAttributes]? {
guard let collectionView = collectionView else { return [layoutAttributes] }
let contentWidth = collectionView.frame.size.width - sectionInset.left - sectionInset.right
var rowFrame = layoutAttributes.frame
rowFrame.origin.x = sectionInset.left
rowFrame.size.width = contentWidth
return super.layoutAttributesForElements(in: rowFrame)
}
let minYs = minimumYs(from: layoutAttributesForRow())
guard let minY = minYs[layoutAttributes.indexPath] else { return layoutAttributes }
layoutAttributes.frame = layoutAttributes.frame.offsetBy(dx: 0, dy: minY - layoutAttributes.frame.origin.y)
return layoutAttributes
}
open override func layoutAttributesForElements(in rect: CGRect) -> [UICollectionViewLayoutAttributes]? {
let attributes = super.layoutAttributesForElements(in: rect)?
.map { $0.copy() } as? [UICollectionViewLayoutAttributes]
let minimumYs = minimumYs(from: attributes)
attributes?.forEach {
guard $0.representedElementCategory == .cell else { return }
guard let minimumY = minimumYs[$0.indexPath] else { return }
$0.frame = $0.frame.offsetBy(dx: 0, dy: minimumY - $0.frame.origin.y)
}
return attributes
}
/// Returns the minimum Y values based for each index path.
private func minimumYs(from layoutAttributes: [UICollectionViewLayoutAttributes]?) -> [IndexPath: CGFloat] {
layoutAttributes?
.reduce([CGFloat: (CGFloat, [UICollectionViewLayoutAttributes])]()) {
guard $1.representedElementCategory == .cell else { return $0 }
return $0.merging([ceil($1.center.y): ($1.frame.origin.y, [$1])]) {
($0.0 < $1.0 ? $0.0 : $1.0, $0.1 + $1.1)
}
}
.values.reduce(into: [IndexPath: CGFloat]()) { result, line in
line.1.forEach { result[$0.indexPath] = line.0 }
} ?? [:]
}
}
I have used something similar to the before answers.
In my case I want to align cells by colum with different heights.
import UIKit
class AlignedCollectionViewFlowLayout: UICollectionViewFlowLayout {
override func layoutAttributesForElements(in rect: CGRect) -> [UICollectionViewLayoutAttributes]? {
if let attributes = super.layoutAttributesForElements(in: rect) {
let sectionElements: [Int : [UICollectionViewLayoutAttributes]] = attributes
.filter {
return $0.representedElementCategory == .cell //take cells only
}.groupBy {
return $0.indexPath.section //group attributes by section
}
sectionElements.forEach { (section, elements) in
//get suplementary view (header) to align each section
let suplementaryView = attributes.first {
return $0.representedElementCategory == .supplementaryView && $0.indexPath.section == section
}
//call align method
alignToTopSameSectionElements(elements, with: suplementaryView)
}
return attributes
}
return super.layoutAttributesForElements(in: rect)
}
private func alignToTopSameSectionElements(_ elements: [UICollectionViewLayoutAttributes], with suplementaryView: UICollectionViewLayoutAttributes?) {
//group attributes by colum
let columElements: [Int : [UICollectionViewLayoutAttributes]] = elements.groupBy {
return Int($0.frame.midX)
}
columElements.enumerated().forEach { (columIndex, object) in
let columElement = object.value.sorted {
return $0.indexPath < $1.indexPath
}
columElement.enumerated().forEach { (index, element) in
var frame = element.frame
if columIndex == 0 {
frame.origin.x = minimumLineSpacing
}
switch index {
case 0:
if let suplementaryView = suplementaryView {
frame.origin.y = suplementaryView.frame.maxY
}
default:
let beforeElement = columElement[index-1]
frame.origin.y = beforeElement.frame.maxY + minimumInteritemSpacing
}
element.frame = frame
}
}
}
}
public extension Array {
func groupBy <U> (groupingFunction group: (Element) -> U) -> [U: Array] {
var result = [U: Array]()
for item in self {
let groupKey = group(item)
if result.has(groupKey) {
result[groupKey]! += [item]
} else {
result[groupKey] = [item]
}
}
return result
}
}
This is the result of this layout:
This may or may not work for your particular situation, but I had some luck subclassing UICollectionViewFlowLayout in the following way:
#interface CustomFlowLayout : UICollectionViewFlowLayout
#end
#implementation CustomFlowLayout
- (NSArray *)layoutAttributesForElementsInRect:(CGRect)rect{
NSArray* attributesToReturn = [super layoutAttributesForElementsInRect:rect];
for (UICollectionViewLayoutAttributes* attributes in attributesToReturn) {
if (nil == attributes.representedElementKind) {
NSIndexPath* indexPath = attributes.indexPath;
attributes.frame = [self layoutAttributesForItemAtIndexPath:indexPath].frame;
}
}
return attributesToReturn;
}
- (UICollectionViewLayoutAttributes *)layoutAttributesForItemAtIndexPath:(NSIndexPath *)indexPath {
UICollectionViewLayoutAttributes *currentItemAttributes = [super layoutAttributesForItemAtIndexPath:indexPath];
currentItemAttributes.frame = CGRectOffset(currentItemAttributes.frame, 0, 0.5 * CGRectGetHeight(currentItemAttributes.frame));
return currentItemAttributes;
}
#end
#DongXu: Your solution worked for me too. Here is the Xamarin.iOS version if it:
public class TopAlignedCollectionViewFlowLayout : UICollectionViewFlowLayout
{
public override UICollectionViewLayoutAttributes[] LayoutAttributesForElementsInRect(CGRect rect)
{
if (base.LayoutAttributesForElementsInRect(rect) is UICollectionViewLayoutAttributes[] attrs)
{
// Find all the cells and group them together by the rows they appear on
var cellsGroupedByRow = attrs
.Where(attr => attr.RepresentedElementCategory == UICollectionElementCategory.Cell)
// The default flow layout aligns cells in the middle of the row.
// Thus, cells with the same Y center point are in the same row.
// Convert to int, otherwise float values can be slighty different for cells on the same row and cause bugs.
.GroupBy(attr => Convert.ToInt32(attr.Frame.GetMidY()));
foreach (var cellRowGroup in cellsGroupedByRow)
{
TopAlignCellsOnSameLine(cellRowGroup.ToArray());
}
return attrs;
}
return null;
}
private static void TopAlignCellsOnSameLine(UICollectionViewLayoutAttributes[] cells)
{
// If only 1 cell in the row its already top aligned.
if (cells.Length <= 1) return;
// The tallest cell has the correct Y value for all the other cells in the row
var tallestCell = cells.OrderByDescending(cell => cell.Frame.Height).First();
var topOfRow = tallestCell.Frame.Y;
foreach (var cell in cells)
{
if (cell.Frame.Y == topOfRow) continue;
var frame = cell.Frame;
frame.Y = topOfRow;
cell.Frame = frame;
}
}
}
I used https://cocoapods.org/pods/AlignedCollectionViewFlowLayout
Install the pod or simply add the file AlignedCollectionViewFlowLayout.swift to your project
In storyboard, select the Collection View's "Collection Layout" and assign the class AlignedCollectionViewFlowLayout
In your View Controller's ViewDidLoad() function add:
let alignedFlowLayout = collectionView?.collectionViewLayout as? AlignedCollectionViewFlowLayout
alignedFlowLayout?.horizontalAlignment = .left
alignedFlowLayout?.verticalAlignment = .top
The UICollectionViewFlowLayout class is derived from the UICollectionViewLayout base class. And if you look at the documentation for that, you'll see there are a number of methods you can override, the most likely candidate being layoutAttributesForItemAtIndexPath:.
If you override that method, you could let it call its super implementation, and then adjust the properties of the returned UICollectionViewLayoutAttributes object. Specifically, you'll likely need to adjust the frame property to reposition the item so it's no longer centered.
I used this code (https://github.com/yoeriboven/TopAlignedCollectionViewLayout) after DongXu's solution didn't quite work. The only modification was that it's originally designed to be used with a grid so I needed to instantiate the layout with an arbitrarily high column count...
let collectionViewFlowLayout = YBTopAlignedCollectionViewFlowLayout(numColumns: 1000)
#DongXu's answer is correct. However, I suggest to make those calculations in UICollectionViewFlowLayout's prepare() method. It will prevent multiple calculations on the same cell's attributes. Moreover, prepare() is better place to manage attributes cache.
is there a way to detect if a header of a section in an UITableView is currently floating? I want to scroll the table view to the header position only if it is floating.
Thanks in advance!
The header will be floating if the first cell of the section is no longer visible. So:
NSIndexPath *topCellPath = [[self.tableView indexPathsForVisibleRows] objectAtIndex:0];
if (topCellPath.row != 0)
{
// Header must be floating!
}
You could achieve a similar effect by scrolling to the index path with scrollToRowAtIndexPath:atScrollPosition:animated: and a scroll position of UITableViewScrollPositionNone - this would not scroll if the first cell in the section was already on the screen.
This works.
#implementation UITableView (rrn_extensions)
-(BOOL)rrn_isFloatingSectionHeaderView:(UITableViewHeaderFooterView *)view {
NSNumber *section = [self rrn_sectionForHeaderFooterView:view];
return [self rrn_isFloatingHeaderInSection:section.integerValue];
}
-(BOOL)rrn_isFloatingHeaderInSection:(NSInteger)section {
CGRect frame = [self rectForHeaderInSection:section];
CGFloat y = self.contentInset.top + self.contentOffset.y;
return y > frame.origin.y;
}
-(NSNumber *)rrn_sectionForHeaderFooterView:(UITableViewHeaderFooterView *)view {
for (NSInteger i = 0; i < [self numberOfSections]; i++) {
CGPoint a = [self convertPoint:CGPointZero fromView:[self headerViewForSection:i]];
CGPoint b = [self convertPoint:CGPointZero fromView:view];
if (a.y == b.y) {
return #(i);
}
}
return nil;
}
#end
Adding a swift port of #robdashnash's answer
extension UITableView
{
func isFloatingSectionHeader( view:UITableViewHeaderFooterView )->Bool
{
if let section = section( for:view )
{
return isFloatingHeaderInSection( section:section )
}
return false
}
func isFloatingHeaderInSection( section:Int )->Bool
{
let frame = rectForHeader( inSection:section )
let y = contentInset.top + contentOffset.y
return y > frame.origin.y
}
func section( for view:UITableViewHeaderFooterView )->Int?
{
for i in stride( from:0, to:numberOfSections, by:1 )
{
let a = convert( CGPoint.zero, from:headerView( forSection:i ) )
let b = convert( CGPoint.zero, from:view )
if a.y == b.y
{
return i
}
}
return nil
}
}
iOS 11.0+ solution for UITableViews as well as UICollectionViews
In both cases, use the scrollViewDidScroll method. You can use the currentHeader property to store current header displayed at the top. HEIGHT_OF_HEADER_VIEW is a value you should specify yourself.
For UITableView
private var currentHeader: UITableViewHeaderFooterView? {
willSet {
// Handle `old` header
} didSet {
// Hanlde `new` header
}
}
func scrollViewDidScroll(_ scrollView: UIScrollView) {
let point = CGPoint(x: 0, y: HEIGHT_OF_HEADER_VIEW + tableView.contentOffset.y + tableView.adjustedContentInset.top)
guard let path = tableView.indexPathForRow(at: point) else {
return
}
let section = path.section
let rect = tableView.rectForHeader(inSection: section)
let converted = tableView.convert(rect, to: tableView.superview)
let safeAreaInset = tableView.safeAreaInsets.top
// Adding 1 offset because of large titles that sometimes cause slighly wrong converted values
guard converted.origin.y <= safeAreaInset,
let header = tableView.headerView(forSection: section) else {
return
}
currentHeader = header
}
For UICollectionView
private var currentHeader: UICollectionReusableView? {
willSet {
// Handle `old` header
} didSet {
// Handle `new` header
}
}
func scrollViewDidScroll(_ scrollView: UIScrollView) {
let x = (collectionView.collectionViewLayout as? UICollectionViewFlowLayout)?.sectionInset.left ?? 0
let point = CGPoint(x: x, y: HEIGHT_OF_HEADER_VIEW + collectionView.contentOffset.y + collectionView.adjustedContentInset.top)
guard let path = collectionView.indexPathForItem(at: point),
let rect = collectionView.layoutAttributesForSupplementaryElement(ofKind: UICollectionView.elementKindSectionHeader, at: IndexPath(row: 0, section: path.section))?.frame else {
return
}
let converted = collectionView.convert(rect, to: collectionView.superview)
let safeAreaInset = collectionView.safeAreaInsets.top
guard converted.origin.y <= safeAreaInset,
let header = collectionView.supplementaryView(forElementKind: UICollectionView.elementKindSectionHeader, at: IndexPath(row: 0, section: path.section)) else {
return
}
currentHeader = header
}
You can first store the original rect of section header view.
headerView.originalPoint = [tableView rectForHeaderInSection:section].origin;
And send scrollViewDidScroll:scrollView message to header.
In your header view
- (void)scrollViewDidScroll:(UIScrollView *)scrollView {
BOOL isFloating = self.frame.origin.y > self.originalPoint.y;
if (isFloating) {
//DO what you want
}
}
The code works for me.
- (BOOL)isSection0HeaderSticky {
CGRect originalFrame = [self.listTableView rectForHeaderInSection:0];
UIView *section0 = [self.listTableView headerViewForSection:0];
if (originalFrame.origin.y < section0.frame.origin.y) {
return YES;
}
return NO;
}