DIY 双向滑动列表

本文介绍了在iOS开发中,使用UIScrollView手动生成一个双向滑动列表的实践过程。通过UITableView嵌套ListView,实现了竖向分页加载和横向平滑滑动。文中详细讲解了数据格式设计、请求时机以及可重用Item的实现策略,强调了自定义列表的优势和对UIScrollView理解的重要性。
摘要由CSDN通过智能技术生成

来源:杨萧玉HIT

http://t.cn/RMTwCUG


公司项目中有个页面是双向滑动的列表,遂用 UIScrollView 手撸了之,把一些 UI 和数据上的心得记录下来。


需求简介


UI 上的主要内容就是一个 UITableView,其中一些 cell 内会嵌套一个横向滑动的 ListView,ListView 中包含一些尺寸相同可点击的 ItemView,ItemView 中的图片内容需要从网络加载。


去掉一些跟本文内容无关的细枝末节,可以将原型抽象如下图:



蓝色的为 ItemView,白色为 ListView,灰色为 TableView。


竖向滑动会有分页加载和转菊花停顿,横向滑动无分页加载停顿,要求细腻丝滑。


如果只是单纯一个双向滑动列表,UICollectionView 完全可以胜任。但是真正的需求里还掺杂着很多其他 UI 元素,所以只能 DIY 了。


数据请求


格式设计


因为主体 UI 为竖向列表嵌套横向列表,所以两个方向都需要有数据请求。


为了方便叙述,这里简化下数据格式。


ItemView 对应的数据 ItemModel 包含唯一标识符 itemID 和图片地址 imageURL:


@interface ItemModel: NSObject

@property (nonatomic, strong) NSString *itemID; // Item 唯一标识符

@property (nonatomic, strong) NSString *imageURL; // 封面图片 URL

@end


ListView 对应的数据 ListModel 包含分组唯一标识符 listID,首屏需要展现的 Item 数据 items 和列表所有 Item 的 ID itemIDs。


@interface ListModel: NSObject

@property (nonatomic, strong) NSString *listID; // List 唯一标识符

@property (nonatomic, strong) NSArray *items; // 包含首屏前几个 Item 数据

@property (nonatomic, strong) NSArray *itemIDs; // 包含整行 List 所有 Item 的 ID

@end


竖向滑动时,TableView 会从后台分页获取 ListModel 数组。ListModel 中的 items 填充了初始数据(此时不需要横向滑动拉取数据)。


之所以不干脆在 ListModel 装填所有 ItemModel,是为了节省一次请求的数据流量,降低失败率。因为第一次展示页面时只需要填充前几个 ItemView 的数据,等用户横向滑动后再继续根据 itemIDs 请求滑动到的 ItemView 对应数据。


请求时机


因为有了 ListView 中所有 ItemView 对应的 ID,所以横向滑动没有分页逻辑,就没有转菊花的停顿时间用来请求网络数据和等待接受处理数据。虽然可以用 ID 请求到对应的 ItemModel,但肯定不能在 UITableView 的 datasource 回调中为每个 cell 去单独请求数据。所以 ListView 即便直接使用 UITableView 或 UICollectionView,也无法享受到它带来的便捷,数据请求逻辑依然需要自己在 UIScrollViewDelegate 中实现。所以 ListView 是继承 UIScrollView 自己 DIY 了一个横向滑动列表。如何合理控制数据请求次数成为了性能优化的重点


文中示例代码均经过简化,去除了无关信息,并使用 MRC。


@interface `ListView` : UIScrollView

@property (nonatomic, retain) ListModel *model;

@property (nonatomic, retain) NSMutableArray *itemModels;

@end

 

@implementation

 

const static CGFloat itemWidth = 80; // ItemView 宽度

const static CGFloat itemHeight = 128; // ItemView 高度

const static CGFloat itemGap = 2; // ItemView 间距

const static CGFloat itemFirstToLeft = 0; // 最左边的 ItemView 的 leftMargin

 

#pragma mark - UIScrollViewDelegate

 

- (void)scrollViewDidScroll:(UIScrollView *)scrollView

{

    if (scrollView.contentSize.width == 0) {

        return;

    }

    // 计算滚动的区间

    NSInteger indexLeft = floorf((scrollView.contentOffset.x - itemFirstToLeft) / (itemWidth + itemGap)) - 1;

    indexLeft = MAX(indexLeft, 0);

    indexLeft = MIN(indexLeft, self.model.itemIDs.count - 1);

    

    NSInteger indexRight = ceilf((scrollView.contentOffset.x - itemFirstToLeft + scrollView.width) / (itemWidth + itemGap));

    indexRight = MAX(indexRight, 0);

    indexRight = MIN(indexRight, self.model.itemIDs.count - 1);

    

    NSInteger length = indexRight - indexLeft + 1;

    length = MAX(length, 0);

    length = MIN(length, self.model.itemIDs.count);

    

    // 加载区间内的 Item

    [self loadItems:NSMakeRange(indexLeft, length)];

}

 

- (void)scrollViewWillBeginDragging:(UIScrollView *)scrollView

{

    [self reloadItemModels];

}

 

- (void)scrollViewDidEndDecelerating:(UIScrollView *)scrollView

{

    [self reloadItemModels];

}

 

/**

在指定范围内加载可重用 Item

针对本地没有数据的 itemID 请求数据

@param range 加载的范围

*/

- (void)loadItems:(NSRange)range

{

// 省略...

}

 

/**

刷新页面数据,如果页面上有数据缺失,则再次请求数据

*/

- (void)reloadItemModels

{

// 省略...

}

// 省略一堆逻辑...

@end


在 ListView 中直接请求数据的位置有三处,但 loadItems: 和 reloadItemModels 这两个方法会被其他地方调用,所以可能间接请求数据的时机还是很多的。reloadItemModels 方法也会在接收到网络数据时被调用。loadItems: 计算滚动时屏幕上包含 ItemView 的区间的方法完全是根据 frame 坐标来计算的,并且会预留出屏幕外左右各一个 ItemView。reloadItemModels 方法只是重新装载下数据,因为 itemID 列表是完整的,但每个 itemID 不一定都缓存到了对应的 ItemModel,所以这个方法还对缺失数据进行请求,可以理解为『强刷』。


UIScrollViewDelegate 中实现策略如下:


  1. 在列表滚动过程中计算当前显示在屏幕上的 ItemView 的区间,并调用 loadItems:

  2. 在列表即将开始滚动和停止滚动的时刻,调用 reloadItemModels 重新加载数据


在计算需要加载的 range 的时候需要注意一些边界数值的处理,擅用 floorf 和 ceilf 函数进行取舍,并用 MAX() 和 MIN() 来检查边界值合法性。


可重用的 ItemView


需要自己实现一个类似 UICollectionView 的 item 重用机制。



在用户横向滑动列表时,需要更新此时某个区间的 UI 和数据。这个任务是在 loadItems: 中实现的,原理是计算出需要被加载的 itemID 数组,并与已有的数据进行比较。将不再需要显示在屏幕上的 ItemView 进行回收(enqueue),并从根据 itemID 添加 ItemView 对象(dequeue)。因为一开始只加载了首屏的 ItemModel 数组,所以还需要对本地缓存中缺失的数据进行请求。


/**

在指定范围内加载可重用 Item

针对本地没有数据的 itemID 请求数据

@param range 加载的范围

*/

- (void)loadItems:(NSRange)range

{

    NSMutableArray *itemIDsToLoad = [[self.model.itemIDs subarrayWithRange:range] mutableCopy];

    NSMutableArrayItemView *> *itemsToEnqueue = [NSMutableArray array];

    // 比较需要加载的 itemID 数组和已经显示过的 itemID 数组,计算出需要新添加的 itemID 和不再需要显示而被回收的 Item

    for (ItemView *item in self.visibleItems) {

        NSString *itemIDAlreadyLoad = nil;

        for (NSString *itemIDToLoad in itemIDsToLoad) {

            if ([itemIDToLoad isEqualToString:item.itemID]) {

                itemIDAlreadyLoad = itemIDToLoad;

            }

        }

        if (!itemIDAlreadyLoad) {

            [itemsToEnqueue addObject:item];

        }

        else {

            [itemIDsToLoad removeObject:itemIDAlreadyLoad];

        }

    }

    // 回收不需要显示的 Item

    for (ItemView *item in itemsToEnqueue) {

        [item removeFromSuperview];

        [self.visibleItems removeObject:item];

        [self enqueueReusableItem:item];

    }

    // 添加需要显示的 Item

    NSMutableArray *itemIDsToRequest = [NSMutableArray array];

    for (NSString *itemID in itemIDsToLoad) {

        if (![self addItemViewWithID:itemID]) {

            [itemIDsToRequest addObject:itemID];

        }

    }

    // 针对本地没有数据的 itemID 请求数据

    if (itemIDsToRequest.count > 0) {

        // 向后台请求 itemIDsToRequest 对应数据

    }

    [itemIDsToLoad release];

}


addItemViewWithID: 负责从 reusable items 获取 ItemView 并设置 frame 添加到 ListView 中。因为 ItemView 是重用的,所以如果 dequeue 出的对象正好是上一次刚刚 enqueue 的对象,那么就不用重新填充数据了;否则需要重置 ItemView 的占位图,并填充新数据。ItemView 被填充了数据后会对数据进行处理,比如异步拉取图片资源。


当无法向 ItemView 填充数据时,addItemViewWithID: 会返回 NO,暗示调用方需要用该 itemID 请求数据。


/**

添加 itemID 对应的 Item

 

@param itemID @param itemID 视频 ID

@return 是否成功向 Item 填充数据

*/

- (BOOL)addItemViewWithID:(NSString *)itemID

{

    ItemView *item = [self dequeueReusableItem];

    NSUInteger index = [self.model.itemIDs indexOfObject:itemID];

    

    CGFloat originX = itemFirstToLeft + index * (itemWidth + itemGap);

    item.frame = CGRectMake(originX, 0, itemWidth, itemHeight);

    [self addSubview:item];

    [self.visibleItems addObject:item];

    if ([item.itemID isEqualToString:itemID]) {

        return YES;

    }

    [item useDefaultCover];

    item.itemID = itemID;

    

    for (ItemModel *model in self.itemModels)

    {

        if ([model.itemID isEqualToString:itemID])

        {

            item.model = model;

            return YES;

        }

    }

    return NO;

}


可重用 Item 列表的 dequeue 和 enqueue 操作实现如下,dequeue 也负责在可重用列表不够用时创建新的 ItemView:


/**

从可重用队列中回收 Item

@param item  回收的 Item

*/

- (void)enqueueReusableItem:(ItemView *)item

{

    [self.reusableItems addObject:item];

}

 

/**

从可重用队列中取出 Item

@return  取出的 Item

*/

- (ItemView *)dequeueReusableItem

{

    ItemView *item = [self.reusableItems.firstObject retain];

    if (item) {

        [self.reusableItems removeObject:item];

    }

    else {

        item = [ItemView new];

    }

    return [item autorelease];

}


感受


不得不承认一开始想 DIY 个列表还是有装逼嫌疑的,但后来发现还是 DIY 来的爽,好多事情都掌握在自己手里,批量请求数据也更方便(虽说直接用 UICollectionView 也可以办到,只是有些绕,且依然得自己实现 UIScrollViewDelegate)。最大的收获就是对 UIScrollView 的理解更深刻,对 UITableView 的实现机制有了掌握。


还有就是在做之前把一些事情想的太简单了,然后开始动工后就会发现:卧槽还真就是这么简单!23333333


其实还是遇到了一些项目中的 API 上的坑的,比如工程里的异步加载图片 ImageView 会忽略第二次相同的 url 请求,导致调试时花了些时间。


曾经还设想把可重用的范围扩大到整个屏幕,也就是屏幕上所有横向滑动的 ListView 全都共用一个可重用列表。但后来计算一下这样做收益不高,毕竟横向滑动的 ListView 在正常滑动时可重用列表中会有一到两个 ItemView 对象,而全屏最多同时存在 5 个 ListView,节省的内存很有限。


其实还有许多细节实现没有叙述,比如记录 ListView 上次横向滑动位置,因为 ListView 是加在全屏 TableView 的每个 cell 中,而 cell 也是重用的,所以上次滑动偏移是需要每次 cell 消失时记录和 cell 出现时刷新的。


在公司项目用了快半年的 MRC,有时还要切换到 ARC 做做其他事情,或者来一发 Swift 玩票儿,真是一种奇(dan)妙(teng)的体验。

推荐Java开发者必备微信号 


Java编程精选
微信号:JavaPush



推荐理由:
关注语言编程Java,分享、交流Java编程技巧和信息


 ▼长按下方↓↓↓二维码识别关注


  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 1
    评论
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值