NSCollectionView custom layout enable scrolling
Asked Answered
P

6

7

I can't get scrolling both vertically and horizontally to work with a custom layout for NSCollectionView. According to the docs, in my subclass I return the collectionViewContentSize and if that is too big, scrolling is automatically enabled in the enclosing scroll view of the collection view. However, even if I order all elements in a horizontal row, only vertical scrolling is enabled.

Here is a screenshot: Screenshot

Here is my layout code:

class Layout: NSCollectionViewLayout
{
var cellSize = CGSize(width: 100, height: 30)

var cellSpacing: CGFloat = 10
var sectionSpacing: CGFloat = 20

private var contentSize = CGSize.zero
private var layoutAttributes = [NSIndexPath: NSCollectionViewLayoutAttributes]()


override func prepareLayout() {
    guard let collectionView = collectionView else { return }

    let sections = collectionView.numberOfSections
    guard sections > 0 else { return }

    contentSize.height = cellSize.height

    for section in 0..<sections {
        let items = collectionView.numberOfItemsInSection(section)
        guard items > 0 else { break }

        for item in 0..<items {
            let origin = CGPoint(x: contentSize.width, y: 0)
            let indexPath = NSIndexPath(forItem: item, inSection: section)
            let attributes = NSCollectionViewLayoutAttributes(forItemWithIndexPath: indexPath)
            attributes.frame = CGRect(origin: origin, size: cellSize)
            layoutAttributes[indexPath] = attributes

            contentSize.width += cellSize.width + cellSpacing
        }
        contentSize.width += sectionSpacing
    }
}

override var collectionViewContentSize: NSSize {
    return contentSize
}

override func layoutAttributesForElementsInRect(rect: NSRect) -> [NSCollectionViewLayoutAttributes] {

    return layoutAttributes.values.filter { $0.frame.intersects(rect) }
}

override func layoutAttributesForItemAtIndexPath(indexPath: NSIndexPath) -> NSCollectionViewLayoutAttributes? {
    return layoutAttributes[indexPath]
}

override func shouldInvalidateLayoutForBoundsChange(newBounds: NSRect) -> Bool {
    return false
}
}
Porphyrin answered 24/6, 2016 at 14:50 Comment(4)
In override var collectionViewContentSize, can you print contentSize and self.collectionView.frame (or assimilated)?Citriculture
contentSize: (22200.0, 30.0) frame: (0.0, 0.0, 438.0, 228.0)Porphyrin
And it works vertically, when I change my layout to a vertical column, I can scroll to the last item. Just horizontal scrolling is always disabled.Porphyrin
@Porphyrin Did you ever figure this out?Kwok
E
10

This bug is still ongoing. I've converted Bo Yuan's nasty hack to Swift. (No offence, Bo Yuan, it's not YOUR fault we have to do this horrible workaround! I'm still ripping this code out as soon as there's an official fix though. For the time being this is just going to keep my development efforts going.)

class HackedCollectionView: NSCollectionView {
    override func setFrameSize(_ newSize: NSSize) {
        let size = collectionViewLayout?.collectionViewContentSize ?? newSize
        super.setFrameSize(size)
        if let scrollView = enclosingScrollView {
            scrollView.hasHorizontalScroller = size.width > scrollView.frame.width
        }
    }
}

Note that this definitely needs to go ASAP because it's getting called for every single frame of animation when scrolling. Not nice. Please, please, please someone either fix this issue or find a proper solution. Not being able to scroll horizontally is ridiculous.

Elflock answered 11/2, 2017 at 19:2 Comment(3)
This works, but then I can't resize my window horizontally. :(Sampan
This works for me except that I take the max width because contentSize can be smaller than the view bounds. I can resize the window and scroll in both directions.Sanderson
Unfortunately, this isn't working for me and I've been pulling my hair out trying to get a wider content view in an NSCollectionView. Curious how everyone else got this working. What kind of auto layout properties did you create for the NSScrollView? Was there any other code to set the contentView's frame size? I feel like I've tried a number of things and still can't get this to work.Cockatiel
E
2

It seems that only NSCollectionViewFlowLayout is able to dictate a frame that has a width larger than the parent scroll views frame.

The solution is to subclass NSCollectionViewFlowLayout instead of NSCollectionViewLayout. Treat the subclass like any other layout subclass, but add the critical scrollDirection in prepareLayout().

Here is minimum implementation for a layout that scrolls horizontal, and just sets all the items next to one another.

-(void) prepareLayout
{
    [super prepareLayout];
    self.scrollDirection = NSCollectionViewScrollDirectionHorizontal;
}

-(NSSize) collectionViewContentSize
{
    NSSize itemSize = self.itemSize;
    NSInteger num = [self.collectionView numberOfItemsInSection:0];
    NSSize contentSize = NSMakeSize((num * itemSize.width) + ((num+1) * self.minimumLineSpacing), NSHeight(self.collectionView.frame));
    return contentSize;
}

-(BOOL) shouldInvalidateLayoutForBoundsChange:(NSRect)newBounds { return YES; }

-(NSArray<__kindof NSCollectionViewLayoutAttributes *> *) layoutAttributesForElementsInRect:(NSRect)rect
{
    int numItems = [self.collectionView numberOfItemsInSection:0];
    NSMutableArray* attributes = [NSMutableArray arrayWithCapacity:numItems];
    for (int i=0; i<numItems; i++)
    {
        [attributes addObject:[self layoutAttributesForItemAtIndexPath:[NSIndexPath indexPathForItem:i inSection:0]]];
    }
    return attributes;
}

-(NSCollectionViewLayoutAttributes*) layoutAttributesForItemAtIndexPath:(NSIndexPath *)indexPath
{
    NSSize itemSize = self.itemSize;
    NSRect fr = NSZeroRect;
    fr.size = itemSize;
    fr.origin.y = (NSHeight(self.collectionView.frame) - itemSize.height) / 2.0;
    fr.origin.x = (indexPath.item+1 * self.minimumLineSpacing) + (indexPath.item * itemSize.width);
    NSCollectionViewLayoutAttributes* attr = [NSCollectionViewLayoutAttributes layoutAttributesForItemWithIndexPath:indexPath];
    attr.frame = fr;
    return attr;
}
Exhibitor answered 1/8, 2017 at 23:54 Comment(0)
B
2

Short answer

Make sure your custom layout class responds to scrollDirection message (it means it needs to be exposed to objc runtime):

class MyCustomLayout: NSCollectionViewLayout {
    @objc var scrollDirection: NSCollectionView.ScrollDirection {
        .horizontal
    }
}

Explanation

After spending hours I knew there must be a trick in NSCollectionViewLayout class to let collection view know it should size itself in horizontal direction. And there is a trick builtin layouts using, undocumented and hidden in NSCollectionView's implementation (you can see the stack trace in debugger).

collectionViewContentSize method is called several times during NSCollectionView's layout method is running. There is one private method called NSCollectionView._resizeToFitContentAndClipView. Name of the method is really explanatory but if you look at the implementation details (disassembled code in debugger) you will see. In the beginning it gets the layout class instance and checks if the instance responds to scrollDirection selector, and if it responds this method's behavior change according to value returned by that selector.

It is interesting to read the disassembled code. There are many hints about the process, thanks to ObjC's selector names are not mangled. Also I think there must be an option to enable special collection view debugging, because it spits out some log messages like %@(%p): -_resizeToFitContentAndClipView found layout=%@, scrollDirection=%ld, but I couldn't figure it out how to enable those debug messages.

Boohoo answered 18/4, 2021 at 23:45 Comment(0)
T
1

It's a basically bug in NSCollectionView. As a workaround you can implement the scrollDirection method (from NSCollectionViewFlowLayout) and return NSCollectionViewScrollDirectionHorizontal.

Turbary answered 19/8, 2016 at 21:56 Comment(1)
Can you provide an example? I don't see a scrollDirection method as part of the NSCollectionView delegates.Arel
R
1

I figured out a solution. I created a subclass of NSCollectionView. And use this subclass to replace the default NSCollectionView. I created function to override the setFrameSize. Voila!

-(void)setFrameSize:(NSSize)newSize{

    if (newSize.width != self.collectionViewLayout.collectionViewContentSize.width) {

        newSize.width = self.collectionViewLayout.collectionViewContentSize.width;

    }

    [super setFrameSize:newSize];

    //NSLog(@"setFrameSize%@", CGSizeCreateDictionaryRepresentation(newSize));

}

During my test, the setFrameSize will be invoked and some mechanism set the frame size to {0,0} first then set it again to make the width the same width as the clipview and keep the height from the layout's content size.

Rifleman answered 31/8, 2016 at 14:33 Comment(4)
to Peder: it is a custom NSCollectionViewLayout, not the NSCollectionViewFlowLayout. I don't think you can use NSCollectionViewScrollDirectionHorizontal for a custom NSCollectionViewLayout.Rifleman
Pity this can't be converted to Swift :( Let's hope the bug gets fixed soon.Elflock
I take it back, it CAN be Swift-ified by overriding the synthesised property setter.Elflock
Thanks for the swift code, Ash. I think it might be related with AutoLayout on Mac OS because the frame was reset to {0, 0} always then same as the clipview. Hope someone can figure out a better solution or someone from Apple can fix it if it is actually a bug.Rifleman
H
0

It's my solution, similar to Umur Gedik's answer

#import <Cocoa/Cocoa.h>
#import <objc/message.h>

OBJC_EXPORT id objc_msgSendSuper2(void);

@interface _CollectionView : NSCollectionView
@end

@implementation _CollectionView

- (BOOL)_autoConfigureScrollers {
    return NO;
}

- (void)_resizeToFitContentAndClipView {
    struct objc_super superInfo = { self, [self class] };
    ((void (*)(struct objc_super *, SEL))objc_msgSendSuper2)(&superInfo, _cmd);
    
    [self setFrameSize:self.collectionViewLayout.collectionViewContentSize];
}

@end
Henry answered 16/5, 2024 at 16:19 Comment(0)

© 2022 - 2025 — McMap. All rights reserved.