如何優雅地動態插入資料到UITableView

foolish-boy發表於2019-03-03

任他風吹雨打,我自巋然不動!

當我們實時往UITableView中插入資料並重新整理列表的時候,會發現列表是有抖動的。比如在微信聊天頁面,你滑動到某一個位置保持住,然後收到一個或者若干人的微信(這幾個人不在當前聊天列表中)。你會發現每收到一個人的資訊,列表向下沉,就是有一個“抖動”的過程。當然,並不是說微信體驗不好,只是拋磚引玉。

言歸正傳,我要討論的場景如下:

當前列表展示了很多新聞,同時後臺在載入第三方廣告。廣告載入完成後需要按照規定的位置順序迴圈地插入到列表中,比如第5,12,19,26...,要求插入廣告後當前展示的頁面沒有下沉抖動現象,避免剛剛看的新聞跳到不可知的位置去了。

由於這裡廣告不是直接附加在列表末尾,也不是一次性插入到相鄰的位置,而是離散地分佈在整個列表中,所以不好用
insertRowsAtIndexPaths:withRowAnimation:或者
reloadRowsAtIndexPaths:withRowAnimation:區域性重新整理,必須對整個列表ReloadData。顯然這會導致列表下沉抖動,最壞的情況是當前展示的整個頁面下沉,這對於新聞客戶端來說體驗很不好。

首先,我會想到scrollToRowAtIndexPath:atScrollPosition:animated:這個方法。在我重新整理完整個列表之後,再將UITableView滾動到之前記錄的位置。大致思路看程式碼:

//重新整理列表之前找到當前屏最頂部的新聞Id
- (NSString *)topNewsId {
    NSArray *visibleCells = [self.tableView visibleCells];

    UITableViewCell *cell = [visibleCells firstObject];
    NSIndexPath *indexPath = [self.tableView indexPathForCell:cell];
    NewsModel *topNews = [self.dataArr objectAtIndex:indexPath.row];

    NSString *newsId = = topNews.newsId;
    return newsId;
}
//重新整理之後再將之前頂部的新聞滾動到頂部 避免頁面抖動
- (void)keepTopNews:(NSString *)topNewsId {
    int topNewsRow = 0;
    for (int i = 0; i <[self.dataArr count] ; i ++) {
        id data = [self.dataArr objectAtIndex:i];
        if ([data isKindOfClass:[NewsModel class]]) {
            NewsModel *model = data;
            if ([model.newsId isEqualToString:topNewsId]) {
                topNewsRow = i;
                break;
            }
        }
    }
    if (topNewsRow) {
        NSIndexPath *toIndex = [NSIndexPath indexPathForRow:topNewsRow inSection:0];
        [self.tableView scrollToRowAtIndexPath:toIndex atScrollPosition:UITableViewScrollPositionTop animated:NO];
    }

}複製程式碼

乍一看,這種方法挺優美的,也好像能達到我們的目的。但實際上還是有問題的,問題出在visibleCells這個方法。先來看看這個方法的定義:

Returns an array of visible cells currently displayed by the collection view.

即返回當前展示的可見cell陣列。
不過,這個方法並不是"眼見為實的",有時候我們肉眼看不到的cell它卻認為是可見的,或者只部分可見的它也會返回給我們的。比如圖中網易新聞最上面的新聞 “...夫人鏡頭裡的民國世相”就只見到一部分,如果用它來置頂也是會有下沉抖動問題的。

網易新聞截圖
網易新聞截圖

那麼還有沒有更優雅的方式呢?Absolutely!!!

既然用cell做單位來滾動太粗糙,我們可以用畫素級別滾動來優雅地保持置頂新聞巋然不動。

首先我們要知道ReloadData的一個特性:

When you call this method, the collection view discards any currently visible items and views and redisplays them. For efficiency, the collection view displays only the items and supplementary views that are visible after reloading the data. If the collection view’s size changes as a result of reloading the data, the collection view adjusts its scrolling offsets accordingly.

關於ContentOffset、ContentSize、ContentInset的區別這裡就不贅述了,可以參考這裡

就是說ReloadData只重新整理當前螢幕可見的哪些cell,只會對visibleCells呼叫
tableView:cellForRowAtIndexPath:contentOffset是保持不變的,所以我們才看到了“抖動現象”,就像新聞被擠下去了。

contentOffset模擬圖
contentOffset模擬圖

圖中灰色部分表示iPhone的螢幕,粉紅色表示所有資料的佈局大小,白色單元是隱藏在螢幕上方的資料,綠色表示目標廣告單于格。

左圖的當前螢幕最上面的新聞是news 11,UITableview的contentOffset是200,我們可以計算出news 11之前所有新聞單元格的高度總和得出現在news 11的偏移量preOffset。

右圖是在第三個位置插入一個廣告後的佈局。UITableview的contentOffset還是200,但是news 11被“擠下去”了。我們同樣可以計算news 11之前所有新聞單元格和廣告單元格的高度總和得出現在news 11的偏移量afterOffset。

有了preOffset和afterOffset之後就可以知道news 11被“擠下去”多少距離

deltaOffset = afterOffset - preOffset;

那麼,為了保證news 11還是展示在當初的位置,我們只要手動更新ContentOffset的值就可以了,相當於將粉紅色部分上移deltaOffset的距離。

看程式碼:

- (void)insertAds:(NSArray *)ads {
    NSString *topNewsId = [self topNewsId];

    CGFloat preOffset = [self offSetOfTopNews:topNewsId];

    /*
    插入廣告...
    */

    [self.tableView reloadData];

    CGFloat afterOffset = [self offSetOfTopNews:topNewsId];

    CGFloat deltaOffset = afterOffset - preOffset;

    CGPoint contentOffet = [self.tableView contentOffset];
    contentOffet.y += deltaOffset;
    self.tableView .contentOffset = contentOffet;
}

//計算newsId對應新聞的偏移量
- (CGFloat)offSetOfTopNews:(NSString *)newsId {
    CGFloat offset = 0;
    for (int i = 0; i < [self.dataArr count]; i ++) {
        id data = [self.dataArr objectAtIndex:i];
        if ([data isKindOfClass:[NewsModel class]]) {
            NewsModel *model = data;
            if ([model.newsId isEqualToString:newsId]) {
                break;
            }
        }
        NSIndexPath *indexPath = [NSIndexPath indexPathForRow:i inSection:0];
        CGFloat height = [self heightForRowAtIndexPath:indexPath];
        offset += height;
    }
    return offset;
}複製程式碼

如此,就可以真正做到當前螢幕一點都不下沉了。如果廣告插在當前螢幕之外,使用者是感覺不到的,等滑動列表才能在相應位置看到廣告;如果插入到當前螢幕中,使用者在課間區域看到插入一個新聞,但是置頂的新聞位置是保持不動的。

盡享絲滑~

最後稍微提一下計算偏移量中用到的一個小技巧。

如果所有的新聞和廣告單元的高度是固定的,那麼heightForRowAtIndexPath:是很方便計算的。如果是動態的,就需要用到一點技巧了。

比如廣告的資料用AdModel表示。為了讓廣告單元的高度隨廣告內容動態調整,我們一般習慣在AdModel裡用一個cellHeight欄位。

@interface AdModel:NSObject

@property (nonatomic, assign) NSInteger adId;
...
@property (nonatomic, assign) CGFloat   cellHeight;

@end複製程式碼

在我們填充內容渲染廣告位的時候算出高度再賦值給cellHeight

在上面的場景下,前面雖然插入了廣告,但是ReloadData的時候,UITableView並不會重新整理不可見的廣告位,因此cellHeight始終為0,這就導致heightForRowAtIndexPath:不能計算出正確的結果。

巧妙地,我們在廣告插入self.dataArr的時候定義一個臨時的廣告單元變數AdCell,並主動呼叫渲染的介面來給cellHeight賦值。

AdCell *tmpCell = [AdCell new];
[tmpCell setAdsContent:model];//這裡會渲染廣告位並計算出cellHeight複製程式碼

相關文章