Par défaut, Collection View conserve le contenu en décalage lors de l'insertion de cellules. D'autre part, j'aimerais insérer des cellules au-dessus des cellules actuellement affichées afin qu'elles apparaissent au-dessus de l'écran, comme le fait Messages.app lorsque vous chargez des messages précédents. Est-ce que quelqu'un sait comment y parvenir?
C'est la technique que j'utilise. J'ai constaté que d'autres provoquaient d'étranges effets secondaires tels que le scintillement de l'écran:
CGFloat bottomOffset = self.collectionView.contentSize.height - self.collectionView.contentOffset.y;
[CATransaction begin];
[CATransaction setDisableActions:YES];
[self.collectionView performBatchUpdates:^{
[self.collectionView insertItemsAtIndexPaths:indexPaths];
} completion:^(BOOL finished) {
self.collectionView.contentOffset = CGPointMake(0, self.collectionView.contentSize.height - bottomOffset);
}];
[CATransaction commit];
Mon approche s'appuie sur la disposition des flux de sous-classes. Cela signifie que vous n'avez pas à pirater le code de défilement/mise en page dans un contrôleur de vue. L'idée est que chaque fois que vous savez que vous insérez des cellules par-dessus, vous définissez une propriété personnalisée, vous signalez que la prochaine mise à jour de la présentation insérera des cellules par-dessus et que vous vous souviendrez de la taille du contenu avant la mise à jour. Ensuite, vous substituez prepareLayout () et définissez le décalage de contenu souhaité à cet emplacement. Cela ressemble à quelque chose comme ça:
définir des variables
private var isInsertingCellsToTop: Bool = false
private var contentSizeWhenInsertingToTop: CGSize?
override prepareLayout()
et après avoir appelé super
if isInsertingCellsToTop == true {
if let collectionView = collectionView, oldContentSize = contentSizeWhenInsertingToTop {
let newContentSize = collectionViewContentSize()
let contentOffsetY = collectionView.contentOffset.y + (newContentSize.height - oldContentSize.height)
let newOffset = CGPointMake(collectionView.contentOffset.x, contentOffsetY)
collectionView.setContentOffset(newOffset, animated: false)
}
contentSizeWhenInsertingToTop = nil
isInsertingMessagesToTop = false
}
let amount = 5 // change this to the amount of items to add
let section = 0 // change this to your needs, too
let contentHeight = self.collectionView!.contentSize.height
let offsetY = self.collectionView!.contentOffset.y
let bottomOffset = contentHeight - offsetY
CATransaction.begin()
CATransaction.setDisableActions(true)
self.collectionView!.performBatchUpdates({
var indexPaths = [NSIndexPath]()
for i in 0..<amount {
let index = 0 + i
indexPaths.append(NSIndexPath(forItem: index, inSection: section))
}
if indexPaths.count > 0 {
self.collectionView!.insertItemsAtIndexPaths(indexPaths)
}
}, completion: {
finished in
print("completed loading of new stuff, animating")
self.collectionView!.contentOffset = CGPointMake(0, self.collectionView!.contentSize.height - bottomOffset)
CATransaction.commit()
})
Je l'ai fait en deux lignes de code (bien que ce soit sur UITableView) mais je pense que vous pourriez le faire de la même manière.
J'ai fait pivoter la table à 180 degrés.
Ensuite, j'ai également fait pivoter chaque cellule de la table à 180 degrés.
Cela signifiait que je pouvais le traiter comme une table standard de haut en bas, mais le bas était traité comme le haut.
Ajoutant à la réponse de Fogmeister (avec le code), l’approche la plus propre consiste à inverser (à l’envers) la UICollectionView
de manière à obtenir une vue de défilement collante vers le bas plutôt que vers le haut. Cela fonctionne également pour UITableView
, comme le souligne Fogmeister.
- (void)viewDidLoad
{
[super viewDidLoad];
self.collectionView.transform = CGAffineTransformMake(1, 0, 0, -1, 0, 0);
}
En rapide:
override func viewDidLoad() {
super.viewDidLoad()
collectionView.transform = CGAffineTransformMake(1, 0, 0, -1, 0, 0)
}
Cela a pour effet secondaire d'afficher également vos cellules à l'envers, de sorte que vous devez également les retourner. Nous transférons donc la transformation (cell.transform = collectionView.transform
) comme suit:
- (UICollectionViewCell *)collectionView:(UICollectionView *)collectionView cellForItemAtIndexPath:(NSIndexPath *)indexPath
{
UICollectionViewCell *cell = [collectionView dequeueReusableCellWithReuseIdentifier:@"Cell" forIndexPath:indexPath];
cell.transform = collectionView.transform;
return cell;
}
En rapide:
func collectionView(collectionView: UICollectionView, cellForItemAtIndexPath indexPath: NSIndexPath) -> UICollectionViewCell {
var cell = collectionView.dequeueReusableCellWithReuseIdentifier("Cell", forIndexPath: indexPath) as! UICollectionViewCell
cell.transform = collectionView.transform
return cell
}
Enfin, la principale chose à retenir lors du développement sous cette conception est que les paramètres NSIndexPath
dans les délégués sont inversés. Donc, indexPath.row == 0
est la ligne en bas de la collectionView
où il se trouve normalement en haut.
Cette technique est utilisée dans de nombreux projets open source pour produire le comportement décrit, y compris le populaire SlackTextViewController ( https://github.com/slackhq/SlackTextViewController ) géré par Slack
Je pensais ajouter du contexte à la réponse fantastique de Fogmeister!
J'adore la solution de James Martin. Mais pour moi, cela a commencé à s'effriter lors de l'insertion/suppression au-dessus/en dessous d'une fenêtre de contenu spécifique. J'ai essayé de sous-classer UICollectionViewFlowLayout pour obtenir le comportement souhaité. J'espère que ça aide quelqu'un. Tout commentaire apprécié :)
@interface FixedScrollCollectionViewFlowLayout () {
__block float bottomMostVisibleCell;
__block float topMostVisibleCell;
}
@property (nonatomic, assign) BOOL isInsertingCellsToTop;
@property (nonatomic, strong) NSArray *visableAttributes;
@property (nonatomic, assign) float offset;;
@end
@implementation FixedScrollCollectionViewFlowLayout
- (id)initWithCoder:(NSCoder *)aDecoder {
self = [super initWithCoder:aDecoder];
if (self) {
_isInsertingCellsToTop = NO;
}
return self;
}
- (id)init {
self = [super init];
if (self) {
_isInsertingCellsToTop = NO;
}
return self;
}
- (void)prepareLayout {
NSLog(@"prepareLayout");
[super prepareLayout];
}
- (NSArray *)layoutAttributesForElementsInRect:(CGRect)rect {
NSLog(@"layoutAttributesForElementsInRect");
self.visableAttributes = [super layoutAttributesForElementsInRect:rect];
self.offset = 0;
self.isInsertingCellsToTop = NO;
return self.visableAttributes;
}
- (void)prepareForCollectionViewUpdates:(NSArray *)updateItems {
bottomMostVisibleCell = -MAXFLOAT;
topMostVisibleCell = MAXFLOAT;
CGRect container = CGRectMake(self.collectionView.contentOffset.x, self.collectionView.contentOffset.y, self.collectionView.frame.size.width, self.collectionView.frame.size.height);
[self.visableAttributes enumerateObjectsUsingBlock:^(UICollectionViewLayoutAttributes *attributes, NSUInteger idx, BOOL *stop) {
CGRect currentCellFrame = attributes.frame;
CGRect containerFrame = container;
if(CGRectIntersectsRect(containerFrame, currentCellFrame)) {
float x = attributes.indexPath.row;
if (x < topMostVisibleCell) topMostVisibleCell = x;
if (x > bottomMostVisibleCell) bottomMostVisibleCell = x;
}
}];
NSLog(@"prepareForCollectionViewUpdates");
[super prepareForCollectionViewUpdates:updateItems];
for (UICollectionViewUpdateItem *updateItem in updateItems) {
switch (updateItem.updateAction) {
case UICollectionUpdateActionInsert:{
NSLog(@"UICollectionUpdateActionInsert %ld",updateItem.indexPathAfterUpdate.row);
if (topMostVisibleCell>updateItem.indexPathAfterUpdate.row) {
UICollectionViewLayoutAttributes * newAttributes = [self layoutAttributesForItemAtIndexPath:updateItem.indexPathAfterUpdate];
self.offset += (newAttributes.size.height + self.minimumLineSpacing);
self.isInsertingCellsToTop = YES;
}
break;
}
case UICollectionUpdateActionDelete: {
NSLog(@"UICollectionUpdateActionDelete %ld",updateItem.indexPathBeforeUpdate.row);
if (topMostVisibleCell>updateItem.indexPathBeforeUpdate.row) {
UICollectionViewLayoutAttributes * newAttributes = [self layoutAttributesForItemAtIndexPath:updateItem.indexPathBeforeUpdate];
self.offset -= (newAttributes.size.height + self.minimumLineSpacing);
self.isInsertingCellsToTop = YES;
}
break;
}
case UICollectionUpdateActionMove:
NSLog(@"UICollectionUpdateActionMoveB %ld", updateItem.indexPathBeforeUpdate.row);
break;
default:
NSLog(@"unhandled case: %ld", updateItem.indexPathBeforeUpdate.row);
break;
}
}
if (self.isInsertingCellsToTop) {
if (self.collectionView) {
[CATransaction begin];
[CATransaction setDisableActions:YES];
}
}
}
- (void)finalizeCollectionViewUpdates {
CGPoint newOffset = CGPointMake(self.collectionView.contentOffset.x, self.collectionView.contentOffset.y + self.offset);
if (self.isInsertingCellsToTop) {
if (self.collectionView) {
self.collectionView.contentOffset = newOffset;
[CATransaction commit];
}
}
}
Code de version de Swift 3: basé sur la réponse de James Martin
let amount = 1 // change this to the amount of items to add
let section = 0 // change this to your needs, too
let contentHeight = self.collectionView.contentSize.height
let offsetY = self.collectionView.contentOffset.y
let bottomOffset = contentHeight - offsetY
CATransaction.begin()
CATransaction.setDisableActions(true)
self.collectionView.performBatchUpdates({
var indexPaths = [NSIndexPath]()
for i in 0..<amount {
let index = 0 + i
indexPaths.append(NSIndexPath(item: index, section: section))
}
if indexPaths.count > 0 {
self.collectionView.insertItems(at: indexPaths as [IndexPath])
}
}, completion: {
finished in
print("completed loading of new stuff, animating")
self.collectionView.contentOffset = CGPoint(x: 0, y: self.collectionView.contentSize.height - bottomOffset)
CATransaction.commit()
})
Inspiré par la solution de Bryan Pratte, j'ai développé la sous-classe de UICollectionViewFlowLayout pour obtenir le comportement de discussion sans changer la vue de la collection. Cette structure est écrite en Swift 3 et peut être utilisée de manière absolue avec RxSwift et RxDataSources car l'interface utilisateur est complètement séparée de toute logique ou liaison.
Trois choses étaient importantes pour moi:
setContentOffset
au lieu de scrollToItemAtIndexPath
.Ma solution:https://Gist.github.com/jochenschoellig/04ffb26d38ae305fa81aeb711d043068
Voici une version légèrement modifiée de la solution de Peter (structure du flux de sous-classes, pas d’approche à l’envers, légère). C'est Swift 3 . Note UIView.animate
avec une durée nulle - pour autoriser l'animation de la planéité/de l'étrangeté des cellules (ce qui est sur une rangée), mais arrêtez le changement d'animation de la fenêtre d'affichage (ce qui serait terrible)
Usage:
let layout = self.collectionview.collectionViewLayout as! ContentSizePreservingFlowLayout
layout.isInsertingCellsToTop = true
self.collectionview.performBatchUpdates({
if let deletionIndexPaths = deletionIndexPaths, deletionIndexPaths.count > 0 {
self.collectionview.deleteItems(at: deletionIndexPaths.map { return IndexPath.init(item: $0.item+twitterItems, section: 0) })
}
if let insertionIndexPaths = insertionIndexPaths, insertionIndexPaths.count > 0 {
self.collectionview.insertItems(at: insertionIndexPaths.map { return IndexPath.init(item: $0.item+twitterItems, section: 0) })
}
}) { (finished) in
completionBlock?()
}
Voici ContentSizePreservingFlowLayout
dans son intégralité:
class ContentSizePreservingFlowLayout: UICollectionViewFlowLayout {
var isInsertingCellsToTop: Bool = false {
didSet {
if isInsertingCellsToTop {
contentSizeBeforeInsertingToTop = collectionViewContentSize
}
}
}
private var contentSizeBeforeInsertingToTop: CGSize?
override func prepare() {
super.prepare()
if isInsertingCellsToTop == true {
if let collectionView = collectionView, let oldContentSize = contentSizeBeforeInsertingToTop {
UIView.animate(withDuration: 0, animations: {
let newContentSize = self.collectionViewContentSize
let contentOffsetY = collectionView.contentOffset.y + (newContentSize.height - oldContentSize.height)
let newOffset = CGPoint(x: collectionView.contentOffset.x, y: contentOffsetY)
collectionView.contentOffset = newOffset
})
}
contentSizeBeforeInsertingToTop = nil
isInsertingCellsToTop = false
}
}
}
if ([newMessages count] > 0)
{
[self.collectionView reloadData];
if (hadMessages)
[self.collectionView scrollToItemAtIndexPath:[NSIndexPath indexPathForItem:[newMessages count] inSection:0] atScrollPosition:UICollectionViewScrollPositionTop animated:NO];
}
Cela semble fonctionner jusqu'à présent. Rechargez la collection, faites défiler le premier message précédemment vers le haut sans animation.
Ce n’est pas la solution la plus élégante, mais tout à fait simple et efficace, que je me suis fixée pour le moment. Fonctionne uniquement avec une mise en page linéaire (pas de grille) mais ça me convient.
// retrieve data to be inserted
NSArray *fetchedObjects = [managedObjectContext executeFetchRequest:fetchRequest error:nil];
NSMutableArray *objects = [fetchedObjects mutableCopy];
[objects addObjectsFromArray:self.messages];
// self.messages is a DataSource array
self.messages = objects;
// calculate index paths to be updated (we are inserting
// fetchedObjects.count of objects at the top of collection view)
NSMutableArray *indexPaths = [NSMutableArray new];
for (int i = 0; i < fetchedObjects.count; i ++) {
[indexPaths addObject:[NSIndexPath indexPathForItem:i inSection:0]];
}
// calculate offset of the top of the displayed content from the bottom of contentSize
CGFloat bottomOffset = self.collectionView.contentSize.height - self.collectionView.contentOffset.y;
// performWithoutAnimation: cancels default collection view insertion animation
[UIView performWithoutAnimation:^{
// capture collection view image representation into UIImage
UIGraphicsBeginImageContextWithOptions(self.collectionView.bounds.size, NO, 0);
[self.collectionView drawViewHierarchyInRect:self.collectionView.bounds afterScreenUpdates:YES];
UIImage *snapshotImage = UIGraphicsGetImageFromCurrentImageContext();
UIGraphicsEndImageContext();
// place the captured image into image view laying atop of collection view
self.snapshot.image = snapshotImage;
self.snapshot.hidden = NO;
[self.collectionView performBatchUpdates:^{
// perform the actual insertion of new cells
[self.collectionView insertItemsAtIndexPaths:indexPaths];
} completion:^(BOOL finished) {
// after insertion finishes, scroll the collection so that content position is not
// changed compared to such prior to the update
self.collectionView.contentOffset = CGPointMake(0, self.collectionView.contentSize.height - bottomOffset);
[self.collectionView.collectionViewLayout invalidateLayout];
// and hide the snapshot view
self.snapshot.hidden = YES;
}];
}];
J'ai réussi à écrire une solution qui fonctionne pour les cas lors de l'insertion simultanée de cellules en haut et en bas.
// get the top cell and save frame
NSMutableArray<NSIndexPath*> *visibleCells = [self.collectionView indexPathsForVisibleItems].mutableCopy;
NSSortDescriptor *sortDescriptor = [[NSSortDescriptor alloc] initWithKey:@"item" ascending:YES];
[visibleCells sortUsingDescriptors:@[sortDescriptor]];
ChatMessage *m = self.chatMessages[visibleCells.firstObject.item];
UICollectionViewCell *topCell = [self.collectionView cellForItemAtIndexPath:visibleCells.firstObject];
CGRect topCellFrame = topCell.frame;
CGRect navBarFrame = [self.view convertRect:self.participantsView.frame toView:self.collectionView];
CGFloat offset = CGRectGetMaxY(navBarFrame) - topCellFrame.Origin.y;
[self.collectionView reloadData];
// scroll to the old cell position
NSUInteger messageIndex = [self.chatMessages indexOfObject:m];
UICollectionViewLayoutAttributes *attr = [self.collectionView layoutAttributesForItemAtIndexPath:[NSIndexPath indexPathForItem:messageIndex inSection:0]];
self.collectionView.contentOffset = CGPointMake(0, attr.frame.Origin.y + offset);
C’est ce que j’ai appris de JSQMessagesViewController: Comment maintenir la position de défilement? . Très simple, utile et sans scintillement!
// Update collectionView dataSource
data.insert(contentsOf: array, at: startRow)
// Reserve old Offset
let oldOffset = self.collectionView.contentSize.height - self.collectionView.contentOffset.y
// Update collectionView
collectionView.reloadData()
collectionView.layoutIfNeeded()
// Restore old Offset
collectionView.contentOffset = CGPoint(x: 0, y: self.collectionView.contentSize.height - oldOffset)
J'ai trouvé que les cinq étapes fonctionnent de manière transparente:
Préparez les données pour vos nouvelles cellules et insérez-les comme il convient
Dites UIView
pour arrêter l'animation
UIView.setAnimationsEnabled(false)
Effectivement insérer ces cellules
collectionView?.insertItems(at: indexPaths)
Faire défiler la vue de collection (qui est une sous-classe de UIScrollView
)
scrollView.contentOffset.y += CELL_HEIGHT * CGFloat(ITEM_COUNT)
Avis de remplacer CELL_HEIGHT par la hauteur de vos cellules (ce qui n’est facile que si les cellules ont une taille fixe). Il est important d’ajouter des marges/encarts de cellule à cellule.
N'oubliez pas de dire à UIView
de redémarrer l'animation:
UIView.setAnimationsEnabled(true)
Bien que toutes les solutions ci-dessus fonctionnent pour moi, la principale raison de l'échec est que, lorsque l'utilisateur fait défiler ces éléments en cours d'ajout, le défilement s'arrête ou bien il se produit un décalage notable maintenir la position de défilement (visuel) tout en ajoutant des éléments en haut.
class Layout: UICollectionViewFlowLayout {
var heightOfInsertedItems: CGFloat = 0.0
override func targetContentOffset(forProposedContentOffset proposedContentOffset: CGPoint) -> CGPoint {
var offset = proposedContentOffset
offset.y += heightOfInsertedItems
heightOfInsertedItems = 0.0
return offset
}
override func targetContentOffset(forProposedContentOffset proposedContentOffset: CGPoint, withScrollingVelocity velocity: CGPoint) -> CGPoint {
var offset = proposedContentOffset
offset.y += heightOfInsertedItems
heightOfInsertedItems = 0.0
return offset
}
override func prepare(forCollectionViewUpdates updateItems: [UICollectionViewUpdateItem]) {
super.prepare(forCollectionViewUpdates: updateItems)
var totalHeight: CGFloat = 0.0
updateItems.forEach { item in
if item.updateAction == .insert {
if let index = item.indexPathAfterUpdate {
if let attrs = layoutAttributesForItem(at: index) {
totalHeight += attrs.frame.height
}
}
}
}
self.heightOfInsertedItems = totalHeight
}
}
Cette disposition mémorise la hauteur des éléments à insérer, puis la prochaine fois que la présentation demandera un décalage, elle compensera le décalage par la hauteur des éléments ajoutés.
Sur la base de la réponse de @Steven, j'ai réussi à insérer des cellules avec défilement vers le bas, sans scintillement (et en utilisant des cellules automatiques), testé sur iOS 12
let oldOffset = self.collectionView!.contentOffset
let oldOffsetDelta = self.collectionView!.contentSize.height - self.collectionView!.contentOffset.y
CATransaction.begin()
CATransaction.setCompletionBlock {
self.collectionView!.setContentOffset(CGPoint(x: 0, y: self.collectionView!.contentSize.height - oldOffsetDelta), animated: true)
}
collectionView!.reloadData()
collectionView!.layoutIfNeeded()
self.collectionView?.setContentOffset(oldOffset, animated: false)
CATransaction.commit()
Quelques-unes des approches suggérées ont eu divers degrés de succès pour moi. J'ai finalement utilisé une variante de l'option de sous-classement et prepareLayout
, Peter Stajger, mettant ma correction de décalage dans finalizeCollectionViewUpdates
. Cependant, aujourd’hui, j’étais en train de regarder une documentation supplémentaire, j’ai trouvé targetContentOffset(forProposedContentOffset proposedContentOffset: CGPoint)
et je pense que cela ressemble beaucoup plus à l’emplacement prévu pour ce type de correction. Donc, ceci est ma mise en œuvre en utilisant cela. Notez que mon implémentation était destinée à une collection horizontale, mais cellsInsertingToTheLeft
pouvait facilement être mis à jour sous la forme cellsInsertingAbove
et le décalage corrigé en conséquence.
class GCCFlowLayout: UICollectionViewFlowLayout {
var cellsInsertingToTheLeft: Int?
override func targetContentOffset(forProposedContentOffset proposedContentOffset: CGPoint) -> CGPoint {
guard let cells = cellsInsertingToTheLeft else { return proposedContentOffset }
guard let collectionView = collectionView else { return proposedContentOffset }
let contentOffsetX = collectionView.contentOffset.x + CGFloat(cells) * (collectionView.bounds.width - 45 + 8)
let newOffset = CGPoint(x: contentOffsetX, y: collectionView.contentOffset.y)
cellsInsertingToTheLeft = nil
return newOffset
}
}
J'ai utilisé l'approche @James Martin, mais si vous utilisez coredata
et NSFetchedResultsController
, la bonne approche consiste à stocker le nombre de messages antérieurs chargés dans_earlierMessagesLoaded
et à vérifier la valeur dans lecontrollerDidChangeContent:
#pragma mark - NSFetchedResultsController
- (void)controllerDidChangeContent:(NSFetchedResultsController *)controller
{
if(_earlierMessagesLoaded)
{
__block NSMutableArray * indexPaths = [NSMutableArray new];
for (int i =0; i<[_earlierMessagesLoaded intValue]; i++)
{
[indexPaths addObject:[NSIndexPath indexPathForRow:i inSection:0]];
}
CGFloat bottomOffset = self.collectionView.contentSize.height - self.collectionView.contentOffset.y;
[CATransaction begin];
[CATransaction setDisableActions:YES];
[self.collectionView performBatchUpdates:^{
[self.collectionView insertItemsAtIndexPaths:indexPaths];
} completion:^(BOOL finished) {
self.collectionView.contentOffset = CGPointMake(0, self.collectionView.contentSize.height - bottomOffset);
[CATransaction commit];
_earlierMessagesLoaded = nil;
}];
}
else
[self finishReceivingMessageAnimated:NO];
}