TextureGroup / Texture

Smooth asynchronous user interfaces for iOS apps.
https://texturegroup.org/
Other
8.01k stars 1.29k forks source link

[Example apps] Adding custom calculated cell heights to the CustomCollectionView example. #116

Open garrettmoon opened 7 years ago

garrettmoon commented 7 years ago

From @marcelofabri on December 18, 2015 19:37

Hi,

This is my first time playing with AsyncDisplayKit, so sorry if this is a stupid question.

I'm trying to modify the CustomCollectionView to support calculated cell heights, as the current version asks a MosaicCollectionViewLayoutDelegate the original cell size and I'd like to avoid it.

I was able to change _itemSizeAtIndexPath in MosaicCollectionViewLayout to this:

- (CGSize)_itemSizeAtIndexPath:(NSIndexPath *)indexPath
{
  ASCollectionView *collectionView = self.collectionView;
  CGSize calculatedSize = [collectionView calculatedSizeForNodeAtIndexPath:indexPath];
  CGSize size = CGSizeMake([self _columnWidthForSection:indexPath.section], calculatedSize.height);
  return size;
}

However, this causes a crash unless I change the ASCollectionView to have asyncDataFetching set to NO.

Is this the "right way"? How would you do this?

Thanks!

Copied from original issue: facebookarchive/AsyncDisplayKit#954

garrettmoon commented 7 years ago

From @appleguy on December 19, 2015 4:18

@marcelofabri Really glad you're asking questions, don't hesitate!

.asyncDataFetching is unconditionally disabled anyway. Are you sure there was any effect from disabling it? Perhaps you fixed the issue in some other way?

Is the code above the only change you've made to the project, or could you share the full modifications you've made to the project so I can run it locally and take a look? I'd love to help! Certainly shouldn't be crashing, so we'll get you set up and running soon.

garrettmoon commented 7 years ago

From @marcelofabri on December 20, 2015 1:52

@appleguy Thanks for the response!

Here's the full project: https://github.com/marcelofabri/AsyncDisplayKit/tree/custom-collection-view-height/examples/CustomCollectionView

Turns out that asyncDataFetching wasn't the problem, but setting the layoutInspector was.

garrettmoon commented 7 years ago

From @fatuhoku on December 22, 2015 16:26

Could this be related to the issue I'm facing at #973?

Yes, it looks like it's very important that the layout object is able to query the CollectionView for the calculated size of an item. Otherwise layout logic is forced to be duplicated, just like in UIKit!

What's the latest on this @marcelofabri?

garrettmoon commented 7 years ago

From @levi on December 24, 2015 11:8

@marcelofabri, @fatuhoku the cell isn't able to query the collection view or its layout object directly because the cell nodes are measured off the main thread. In order to work around this, the layout inspector protocol is provided for you. This is an object that provides information to ASCollectionView into how the internals of the collection view layout behaves. One of these methods provides the constrained size to measure the supplementary nodes within.

The MosaicCollectionViewLayout example is a bit of a contrived example, which defines an exact size size for each cell to measure within. Don't be distracted by the fact it asks a delegate for sizing information, as this is simply a way for the view controller to interface with the individual image assets and provide an appropriate ratio for the images to fit in.

@marcelofabri I see what you're trying to achieve in interfacing with the calculatedSizeForNodeAtIndexPath: method, however, this will likely result in unwanted behavior because the layout is calculated before the cells are measured. What you want to do is pre-measure the cells before the layout is calculated and use their results in prepareLayout. I would suggest exploring calling reloadDataWithAnimationOptions:completion: on the collection view's data source and laying out your collection view items after the fact.

garrettmoon commented 7 years ago

From @fatuhoku on January 11, 2016 16:58

@levi Thanks for pointing me to this thread. Have you got a working example of reloadDataWithAnimationOptions:completion: being used?

I actually arrived at the idea that you probably had to 'pre-measure' the cells but was completely stuck as to how to achieve it, especially while not losing the performance properties of ASDK.

If nodes are instantiated, does the act of pre-measuring the nodes actually instantiate Views or CALayers at all? It shouldn't, right?

Thanks!

P.S. what's the relationship between the proposed method, and the way UIKit was extended with self-sizing in iOS 8? What I'm seeing is, you'll need some sort of prototype cell that gets configured with some data, it gets measured, and then the computed frame gets cached.

P.P.S @levi when you say "pre-measure the cells" you mean "pre-measure the cell nodes" right?

garrettmoon commented 7 years ago

From @fatuhoku on January 11, 2016 17:54

Also, how can the CollectionViewLayout get hold of nodes from the ASCollectionView? All there is is nodeForIndexPath:, but that's meant for initialising new nodes rather than accessing them.

I was able to do the measurement by accessing nodes directly from prepareLayout. However this yields absolutely terrible horrible performance for some reason — the FPS drops dramatically to around 15FPS. I think it's because measurement is quite expensive and prepareLayout gets called repeatedly in the sample project even as you scroll (not sure why).

 for (NSUInteger idx = 0; idx < numberOfItems; idx++) {
      NSUInteger columnIndex = [self _shortestColumnIndexInSection:section];
      NSIndexPath *indexPath = [NSIndexPath indexPathForItem:idx inSection:section];

//      CGSize itemSize = [self _itemSizeAtIndexPath:indexPath];
      CGFloat xOffset = _sectionInset.left + (columnWidth + _columnSpacing) * columnIndex;
      CGFloat yOffset = [_columnHeights[section][columnIndex] floatValue];

      // *** TEMPORARY CODE START: Calulate the itemSize by measuring the cell... (very expensive)
      CGSize maxCellHeight = CGSizeMake(columnWidth, 1024);
      ASCellNode *node = [cv nodeForItemAtIndexPath:indexPath];
      CGSize itemSize = [node measure:maxCellHeight];
      NSLog(@"measured node size @ %@: %@", indexPath, NSStringFromCGSize(itemSize));
      // *** TEMPORARY CODE END

      // Set the frame for the cell... itemSize...
      UICollectionViewLayoutAttributes *attributes = [UICollectionViewLayoutAttributes
                                                      layoutAttributesForCellWithIndexPath:indexPath];
      attributes.frame = CGRectMake(xOffset, yOffset, itemSize.width, itemSize.height);

      _columnHeights[section][columnIndex] = @(CGRectGetMaxY(attributes.frame) + _interItemSpacing.bottom);

      [_itemAttributes[section] addObject:attributes];
      [_allAttributes addObject:attributes];
    }
garrettmoon commented 7 years ago

From @vtsoup on March 8, 2016 7:48

@fatuhoku I wanted my cells to have the same UI as what you described originally, an image with text below it. And I think I might have figured out a solution. I have not done any instrumentation to measure the performance though.

For the sample project, in MosaicCollectionViewLayout, in the _itemSizeAtIndexPath method, I added an estimated height of my text plus any desired padding to the return value, say 100. In prepareLayout method, I queried for the ASCellNode the same way you did and used its calculatedSize property instead of the size retrieved from _itemSizeAtIndexPath method.

In ImageCellNode, I added an ASTextNode and configured it as desired. In the calculateSizeThatFits method, I subtracted the estimated text height of 100 from the passed in constrainedSize. I then measured the _imageNode and the _textNode. The height of the of the returned CGSize was the addition of the two node's heights.

The catch to this whole "solution" is that I am not sure that ALL of the nodes would be measured by the time they are accessed in prepareLayout. I believe they would be, but I have not looked at the underlying implementation to be 100% sure.

Lastly, the performance problem you were seeing during scrolling is because in MosaicCollectionViewLayout, the shouldInvalidateLayoutForBoundsChange method was returning YES when you scroll, which causes prepareLayout to get called over and over. Change that method to always return NO. The code to handle screen size (orientation) changes should probably be in the view controller's viewWillTransitionToSize method.

Hope that helps and is a good way of doing things. As a disclaimer, I am still new to ASDK and UICollectionView.

garrettmoon commented 7 years ago

From @fatuhoku on March 9, 2016 11:45

Hey @vtsoup, thanks so much for your input. Do you happen to have a working demo project of what you said at all? Would be amazingly useful.

garrettmoon commented 7 years ago

From @vtsoup on March 9, 2016 17:40

@fatuhoku Here you go

I branched master and modified two files in the CustomCollectionView sample application.

garrettmoon commented 7 years ago

From @adamastern on March 22, 2016 4:15

I see what you're trying to achieve in interfacing with the calculatedSizeForNodeAtIndexPath: method, however, this will likely result in unwanted behavior because the layout is calculated before the cells are measured

@levi in what context would this happen? My understanding is that ASCollectionView only inserts cells after the the node for the index path has been setup and measured by its dataController (usually on a background thread). This all happens through the ASRangeController delegate methods here. Any call to insert, update or delete items will then trigger a re-layout of the collection view and prepareLayout will be called again. In fact, calling numberOfSections: and numberOfItemsInSection: on ASCollectionView will only return the number of items and sections that have been loaded (and measured) by the dataController here, not the number returned by asyncDatasource

I have setup a custom UICollectionViewLayout that calls : calculatedSizeForNodeAtIndexPath to get the size of the cells and it seems to work fine. What am I missing here?

garrettmoon commented 7 years ago

From @fatuhoku on March 31, 2016 10:28

@vtsoup Thanks sooooooo much for your demo code project! It really helped me get self-sizing cells working well for my app. Laying out the cards was a dream with -layoutSpecThatFits:. It felt like Flexbox without the need for ComponentKit nor React Native.

garrettmoon commented 7 years ago

From @vtsoup on March 31, 2016 15:33

That is awesome @fatuhoku. If you don't mind, could you share a simple project that uses layoutSpecThatFits? I am still using calculateSizeThatFits() in combination with layout() to layout my nodes. Unless I missed something, the provided sample projects don't provide a good (or readable) usage of layoutSpecThatFits. Thanks.

garrettmoon commented 7 years ago

From @fatuhoku on March 31, 2016 15:42

@vtsoup Sure. Here's what the node looks like for my cell's contents:

#import "ContentNode.h"

#import "HexColors.h"
#import "MESGradientNode.h"
#import "ViewModel.h"
#import "NSArray+F.h"

const int kAvatarImageNodeBorderWidth = 2;

@interface ContentNode ()
@property(nonatomic, readonly) ViewModel *viewModel;

@property(nonatomic, strong) ASNetworkImageNode *avatarImageNode;
@property(nonatomic, strong) ASTextNode *authorNameNode;
@property(nonatomic, strong) ASNetworkImageNode *recipeImageNode;
@property(nonatomic, strong) ASTextNode *recipeTitleNode;
@property(nonatomic, strong) MESGradientNode *gradientNode;
@property(nonatomic, strong) ASImageNode *totalDurationIconNode;
@property(nonatomic, strong) ASTextNode *totalDurationTextNode;
@property(nonatomic, strong) ASImageNode *favouritesIconNode;
@property(nonatomic, strong) ASTextNode *favouritesTextNode;
@end

@implementation ContentNode

- (instancetype)initWithViewModel:(ViewModel *)viewModel {
    self = [super init];
    if (self != nil) {

        _viewModel = viewModel;

        self.backgroundColor = [UIColor whiteColor];

        // Border is set on the OUTSIDE of the cornerRadius so must adjust avatarRoundelFrameSize to compensate.
        CGSize avatarRoundelFrameSize = CGSizeMake(58 + 2 * kAvatarImageNodeBorderWidth, 58 + 2 * kAvatarImageNodeBorderWidth);

        UIColor *placeholderColor = [UIColor hx_colorWithHexRGBAString:@"#E7E7E7"];

        _recipeImageNode = [[ASNetworkImageNode alloc] init];
        _recipeImageNode.URL = viewModel.imageUrl;
        _recipeImageNode.placeholderEnabled = YES;
        _recipeImageNode.placeholderColor = placeholderColor;
        _recipeImageNode.placeholderFadeDuration = 0.34;
        [self addSubnode:_recipeImageNode];

        _recipeTitleNode = [[ASTextNode alloc] init];
//        _recipeTitleNode.borderColor = [UIColor yellowColor].CGColor;
//        _recipeTitleNode.borderWidth = 1;
        _recipeTitleNode.maximumNumberOfLines = 2;
        _recipeTitleNode.drawingPriority = 2;
        _recipeTitleNode.attributedString = [[NSAttributedString alloc] initWithString:viewModel.title ?: @""
                                                                            attributes:[self recipeTitleAttributes]];
        [self addSubnode:_recipeTitleNode];

        _gradientNode = [[MESGradientNode alloc] init];
        _gradientNode.preferredFrameSize = CGSizeMake(CGFLOAT_MAX, 40);
        _gradientNode.opaque = NO;
        _gradientNode.layerBacked = YES;
        _gradientNode.alpha = 0.8;
        [self addSubnode:_gradientNode];

        _avatarImageNode = [[ASNetworkImageNode alloc] init];
        _avatarImageNode.URL = viewModel.authorAvatarUrl;
        _avatarImageNode.backgroundColor = [UIColor clearColor];
        _avatarImageNode.placeholderColor = [UIColor colorWithWhite:1.0f alpha:0.f];
        _avatarImageNode.preferredFrameSize = avatarRoundelFrameSize;
        _avatarImageNode.cornerRadius = avatarRoundelFrameSize.width / 2;  // cornerRadius doesn't work with NetworkImageNode.
        _avatarImageNode.borderWidth = kAvatarImageNodeBorderWidth;
        _avatarImageNode.borderColor = [UIColor whiteColor].CGColor;
        _avatarImageNode.clipsToBounds = YES;
        [self addSubnode:_avatarImageNode];

        _authorNameNode = [[ASTextNode alloc] init];
//        _authorNameNode.borderColor = [UIColor yellowColor].CGColor;
//        _authorNameNode.borderWidth = 1;
        _authorNameNode.drawingPriority = 2;
        _authorNameNode.maximumNumberOfLines = 2;
        _authorNameNode.attributedString = [[NSAttributedString alloc] initWithString:viewModel.authorName ?: @"Anonymous"
                                                                           attributes:[self authorNameAttributes]];
        [self addSubnode:_authorNameNode];

        _totalDurationIconNode = [[ASImageNode alloc] init];
        _totalDurationIconNode.image = [UIImage imageNamed:@"minicard-timer-icon"];
        _totalDurationIconNode.drawingPriority = 2;
        _totalDurationIconNode.preferredFrameSize = CGSizeMake(16, 16);
        [self addSubnode:_totalDurationIconNode];

        _totalDurationTextNode = [[ASTextNode alloc] init];
//        _totalDurationTextNode.borderColor = [UIColor yellowColor].CGColor;
//        _totalDurationTextNode.borderWidth = 1;
        _totalDurationTextNode.maximumNumberOfLines = 2;
        _totalDurationTextNode.attributedString = [[NSAttributedString alloc] initWithString:viewModel.totalDuration ?: @"None"
                                                                                  attributes:[self detailTextAttributes]];
        [self addSubnode:_totalDurationTextNode];

        _favouritesIconNode = [[ASImageNode alloc] init];
        _favouritesIconNode.image = [UIImage imageNamed:@"minicard-heart-icon"];
        _favouritesIconNode.drawingPriority = 2;
        _favouritesIconNode.preferredFrameSize = CGSizeMake(16, 16);
        [self addSubnode:_favouritesIconNode];

        _favouritesTextNode = [[ASTextNode alloc] init];
//        _favouritesTextNode.borderColor = [UIColor yellowColor].CGColor;
//        _favouritesTextNode.borderWidth = 1;
        _favouritesTextNode.maximumNumberOfLines = 2;
        _favouritesTextNode.attributedString = [[NSAttributedString alloc] initWithString:viewModel.numberOfFavourites ?: @"0"
                                                                               attributes:[self detailTextAttributes]];
        [self addSubnode:_favouritesTextNode];

        _favouritesIconNode.hidden = _favouritesTextNode.hidden = !self.viewModel.showNumberOfFavourites;
    }
    return self;
}

- (ASLayoutSpec *)layoutSpecThatFits:(ASSizeRange)constrainedSize {

    CGSize mainImageSize = self.viewModel.imageSize;
    CGFloat height = mainImageSize.height;
    CGFloat width = mainImageSize.width;
    CGFloat mainImageAspectRatio = (width == 0 || height == 0) ? 0.5f : height / width;

    ASRatioLayoutSpec *aspectBoundImage = [ASRatioLayoutSpec ratioLayoutSpecWithRatio:mainImageAspectRatio child:_recipeImageNode];
    ASOverlayLayoutSpec *recipeImageOverlaidWithGradient = [ASOverlayLayoutSpec overlayLayoutSpecWithChild:aspectBoundImage
                                                                                                   overlay:[ASRelativeLayoutSpec relativePositionLayoutSpecWithHorizontalPosition:ASRelativeLayoutSpecPositionCenter verticalPosition:ASRelativeLayoutSpecPositionEnd sizingOption:ASRelativeLayoutSpecSizingOptionDefault child:_gradientNode]];

    ASInsetLayoutSpec *insetAspectBoundImage = [ASInsetLayoutSpec insetLayoutSpecWithInsets:UIEdgeInsetsMake(0, 0, 12, 0) child:recipeImageOverlaidWithGradient];

    ASLayoutSpec *avatarAndNameStack = [ASInsetLayoutSpec insetLayoutSpecWithInsets:UIEdgeInsetsMake(0, 10 - kAvatarImageNodeBorderWidth, 0, 0) child:[ASStackLayoutSpec stackLayoutSpecWithDirection:ASStackLayoutDirectionHorizontal spacing:8 justifyContent:ASStackLayoutJustifyContentStart alignItems:ASStackLayoutAlignItemsCenter children:@[_avatarImageNode, _authorNameNode]]];
    ASLayoutSpec *avatarAtLowerLeft = [ASRelativeLayoutSpec relativePositionLayoutSpecWithHorizontalPosition:ASRelativeLayoutSpecPositionStart verticalPosition:ASRelativeLayoutSpecPositionEnd sizingOption:ASRelativeLayoutSpecSizingOptionMinimumSize child:avatarAndNameStack];
    ASLayoutSpec *recipeImageOverlaidWithAvatar = [ASOverlayLayoutSpec overlayLayoutSpecWithChild:insetAspectBoundImage overlay:avatarAtLowerLeft];

    ASLayoutSpec *totalDurationStack = [ASStackLayoutSpec stackLayoutSpecWithDirection:ASStackLayoutDirectionHorizontal spacing:5 justifyContent:ASStackLayoutJustifyContentCenter alignItems:ASStackLayoutAlignItemsStart children:@[_totalDurationIconNode, _totalDurationTextNode]];
    ASLayoutSpec *favouritesStack = [ASStackLayoutSpec stackLayoutSpecWithDirection:ASStackLayoutDirectionHorizontal spacing:5 justifyContent:ASStackLayoutJustifyContentCenter alignItems:ASStackLayoutAlignItemsStart children:@[_favouritesIconNode, _favouritesTextNode]];
    ASLayoutSpec *detailsStack = [ASStackLayoutSpec stackLayoutSpecWithDirection:ASStackLayoutDirectionHorizontal spacing:10 justifyContent:ASStackLayoutJustifyContentCenter alignItems:ASStackLayoutAlignItemsStart children:@[totalDurationStack, favouritesStack]];

    ASStackLayoutSpec *infoStack = [ASStackLayoutSpec stackLayoutSpecWithDirection:ASStackLayoutDirectionVertical spacing:10 justifyContent:ASStackLayoutJustifyContentStart alignItems:ASStackLayoutAlignItemsStart children:@[_recipeTitleNode, detailsStack]];

    ASLayoutSpec *insetInfos = [ASInsetLayoutSpec insetLayoutSpecWithInsets:UIEdgeInsetsMake(3, 10, 0, 10) child:infoStack];

    ASStackLayoutSpec *contentSpec = [ASStackLayoutSpec stackLayoutSpecWithDirection:ASStackLayoutDirectionVertical spacing:0.0 justifyContent:ASStackLayoutJustifyContentStart alignItems:ASStackLayoutAlignItemsStart children:@[recipeImageOverlaidWithAvatar, insetInfos, detailsStack]];
    contentSpec.flexShrink = YES;

    return contentSpec;
}

#pragma mark - Miscellaneous

- (NSDictionary *)authorNameAttributes {
    return @{
            NSFontAttributeName : [UIFont systemFontOfSize:13.0],
            NSForegroundColorAttributeName : [UIColor whiteColor]
    };
}

- (NSDictionary *)recipeTitleAttributes {
    return @{
            NSFontAttributeName : [UIFont systemFontOfSize:18.0],
            NSForegroundColorAttributeName : [UIColor hx_colorWithHexRGBAString:@"#262626"],
    };
}

- (NSDictionary *)detailTextAttributes {
    return @{
            NSFontAttributeName : [UIFont systemFontOfSize:13.0],
            NSForegroundColorAttributeName : [UIColor hx_colorWithHexRGBAString:@"#B2B2B2"]
    };
}

@end

Most of this class could be used as a ASCellNode directly if you like. Just use a ASDisplayNode in place of MESGradientNode.

garrettmoon commented 7 years ago

From @hannahmbanana on April 4, 2016 0:44

@vtsoup - For another example of -layoutSpecThatFits: - check out PhotoCellNode.m in PR #1457. It's an app I submitted that demos ASDK vs UIKit performance for a social photo app feed.

garrettmoon commented 7 years ago

From @vtsoup on April 4, 2016 3:50

@hannahmbanana Thank you Hannah! The ASDKgram sample app looks great. I look forward to diving deeper into the code and making tweaks to test things out.

garrettmoon commented 7 years ago

From @enhancient on December 13, 2016 6:8

@fatuhoku I know it's been a while, but do you have a full example of how you implemented custom calculated cell heights for the CustomCollectionView example project? I also want to achieve a similar thing, am new to AsyncDisplayKit and can't figure out how to pre-calculate the layout (if that was the solution?) to obtain cell height based on the ASLayoutSpec. I tried looking at vtsoup's solution but this uses calculateSizeThatFits().