无线循环的滚动视图方案

UI

我们近期的项目中有两种场景分别是视频流和直播间,由于项目初期直播间或者视频量较少。因此在用户滑动切换直播间或者视频时需要我们做到可以循环滑动。

可循环滚动内容方案

简介

我们近期的项目中有两种场景分别是视频流和直播间,由于项目初期直播间或者视频量较少。因此在用户滑动切换直播间或者视频时需要我们做到可以循环滑动。

框架选择

遇到这几种场景时我们一般都会想到下面三种方案,对于滑动切换这种场景实际上我们要关注的点是:
1、何时确定切换完成
2、如果在切换完成时获取当前要展示元素(view+model)时机以及方法
3、如何滚动到某个具体位置

下面我们带着上面的两个主要问题,讨论下面三个方案可行性和各自的优缺点。

方案一:UICollectionView

1、何时确定切换完成?
1
- (void)collectionView:(UICollectionView *)collectionView didEndDisplayingCell:(UICollectionViewCell *)cell forItemAtIndexPath:(NSIndexPath *)indexPath;

这个方法会在滚动停止时被调用,当然滚动停止并不意味着切换完成因为存在滚动停止时页面并未切换的场景。
因此想要确认切换完成 需要将页面停止时当前的Index与滚动前的Index做对比,进而确认是否完成切换。

2、如果在切换完成时获取当前要展示元素(view+model)时机以及方法?
1
- (nullable UICollectionViewCell *)cellForItemAtIndexPath:(NSIndexPath *)indexPath

在页面滚动停止时获取当前展示页面的IndexPath,但是页面滚动停止代理方法返回的是结束展示的view和indexPath所以这里需要做一下转换

3、如何滚动到某个具体位置
1
- (void)scrollToItemAtIndexPath:(NSIndexPath *)indexPath atScrollPosition:(UICollectionViewScrollPosition)scrollPosition animated:(BOOL)animated;

方案二:UITableView

1、何时确定切换完成?
1
- (void)tableView:(UITableView *)tableView didEndDisplayingCell:(UITableViewCell *)cell forRowAtIndexPath:(NSIndexPath*)indexPath API_AVAILABLE(ios(6.0));

与UICollectionView类似,页面滚动停止时可以拿到完成展示的页面以及IndexPath。

2、如果在切换完成时获取当前要展示元素(view+model)时机以及方法?
1
- (nullable __kindof UITableViewCell *)cellForRowAtIndexPath:(NSIndexPath *)indexPath;   // returns nil if cell is not visible or index path is out of range

获取视图和模型的方法与UICollectionView的一致。

3、如何滚动到某个具体位置
1
- (void)scrollToRowAtIndexPath:(NSIndexPath *)indexPath atScrollPosition:(UITableViewScrollPosition)scrollPosition animated:(BOOL)animated;

该方法与UICollectionView一致。

方案三:UIScrollView

1、何时确定切换完成?
1
- (void)scrollViewDidScroll:(UIScrollView *)scrollView;                                               // any offset changes

该方法的调用时机为页面滚动,调用频率比较高!需要根据页面滚动的contentOffset来判断当前的页码。

1
2
3
4
// called on finger up if the user dragged. decelerate is true if it will continue moving afterwards
- (void)scrollViewDidEndDragging:(UIScrollView *)scrollView willDecelerate:(BOOL)decelerate;

- (void)scrollViewDidEndDecelerating:(UIScrollView *)scrollView; // called when scroll view grinds to a halt

这两个方法表示滚动停止!其调用时机分别是:

1、scrollViewDidEndDragging:

scrollView 结束拖动(松开鼠标停止拖动的那一瞬间调用(水平滚动ScrollView也调用,垂直滚动TableView也调用))

2、scrollViewDidEndDecelerating:

scrollview 减速停止(必须得有快速拖动的动作,scrollView滚动完毕(速度减为0)并且手已经松开的时候调用)

2、如果在切换完成时获取当前要展示元素(view+model)时机以及方法?

滚动完成时获取当前处于第几个位置,使用scrollView的contentOffset.y与单个视图的高度做除法来获取当前滚动到的视图的位置

1
2
3
4
5
6
7
8
9
10
11
12
13
14
CGFloat screenHeigh = self.view.frame.size.height;
NSInteger offsety = scrollView.contentOffset.y;
NSInteger height = screenHeigh;
CGFloat ratio = offsety % height;
NSInteger index = offsety/screenHeigh;
ULLogInfo(@"LIVEMANAGER: scrollViewDidScroll=index=%@",@(index));
if (index == self.currentIndex) {
return;
}
NSLog(@"enter_live_room_new %s, %@", __PRETTY_FUNCTION__, @"1");

if (scrollView == self.tableView && ratio == 0) {
self.currentIndex = index;
}

根据上面的条件可以获取到当前展示的视图的位置

3、如何滚动到某个具体位置
1
2
- (void)setContentOffset:(CGPoint)contentOffset animated:(BOOL)animated;  // animate at constant velocity to new offset

无限循环方案

假设目前有下面几个视图

viewtree

如果我们要实现无限循环的滚动那么实际上我们有两种方案:

  • 1、修改数据源 实现无线循环滚动(缺点较明显)
  • 2、修改滚动视图位置 静默滚动 (详细介绍)

修改数据源 实现无线循环滚动

咱们的滚动视图中,以UITableView为例,因为我们的cell是可复用的所以不用考虑创建大量的view的问题:

我们可以对数据源做大量的复制比如在tableView中做分组

1
- (NSInteger)numberOfSectionsInTableView:(UITableView *)tableView

方法中返回一个固定值 例如1000

1
- (NSInteger)tableView:(UITableView *)tableView numberOfRowsInSection:(NSInteger)section;

中返回我们视图个数

如此我们页面上展示的就是一个有1000个分组 每个分组中有N个row的TableView

在初始化页面展示完成后静默的将页面滚动到中间的分组位置,这样就可以实现循环滚动的效果!

问题:
1、这种方式比较适合每个分组中个数固定的类型 比如banner 但是如果我们在滑动过程中需要动态请求接口来扩充每一个分组中数据的个数 那么这种方式需要频繁操作大量数据源。
2、在滚动过程中如果我们遇到了某些数据是不符合业务条件的(比如直播间中被拉黑),我们在进行过滤操作时需要操作所有的分组。
优点:
在数据量不是太大的情况下,我们不需要对页面做滚动操作。

修改滚动视图位置 静默滚动

这种方法我们不去操作数据源 保证数据源的稳定,只是在临界条件时需要进行静默滚动操作

通过下面这张图我们来更加详细的描述下

下面我们重点介绍下临界点时的处理逻辑

1、当页面滚动到A且为向上滚动时

按照理想的情况 当前情况再次向上滚动我们应该滚动到最末尾的位置。

1、如何判断当前是向上滚动且滚动到了A的位置

1
2
3
if (self.currentIndex == 0 && !self.isFirstEntry) {
// 滚动到了第一个位置
}

2、如何滚动

1
2
3
4
5
6
7
8
9
// 需要滚动到列表的倒数第二个位置
NSInteger rowCount = self.liveListArray.count;
if (rowCount >= 2) {
self.currentIndex = rowCount-2;
[self.tableView scrollToRowAtIndexPath:[NSIndexPath indexPathForRow:self.currentIndex inSection:0] atScrollPosition:UITableViewScrollPositionTop animated:NO];
} else {
// 如果数组的个数小于2 那么证明只有一个直播间不可以滚动
[self canSlideChangeLiveRoom:NO];
}

注意:判断数组的个数是因为如果只有一个数据 不需要滚动

2、当前页面滚动到E且为向下滚动时

这种情况的判断比较简单:

1
2
3
if (self.currentIndex == self.liveListArray.count -1) {
// 滚动到了最后一个
}

滚动到指定位置

1
2
3
4
5
6
7
8
9
// 滚动到最后一个位置 此处的数据为补充数据 需要滚动列表
// 需要滚到第一个位置
if (self.liveListArray.count == 1) {
// 如果当前直播间列表只有一个直播间
[self canSlideChangeLiveRoom:NO];
} else {
self.currentIndex = 1;
[self.tableView scrollToRowAtIndexPath:[NSIndexPath indexPathForRow:self.currentIndex inSection:0] atScrollPosition:UITableViewScrollPositionTop animated:NO];
}

注意:上面两个方法的调用均是在页面滚动停止之后进行!

通过上面这些操作,我们基本实现了一个可以循环滑动的列表!!

但是在应用到实际的场景时我们肯定会遇到数据源的增删问题,那么这种情况下我们如何去处理呢?

异常情况处理

数据源增加

这种情况通常是我们列表中的数据并不是一成不变的比如分页拉取多条数据,那么在这个时候我们应该如何操作呢?

下面我们通过,分页加载数据时如何处理:

假设我们在滚动到倒数第三个数据(有效数据倒数第二条)时我们预加载下一页的内容。

因为需要通过接口返回数据 所以我们无法确认接口返回数据的时机,所以这里要注意处理数据源的时机和方式

假如我们接口返回了2条数据 F G

我们在返回数据后因为之前在数组的首尾都添加了占位的数据,因此数据添加后我们依然要重置这两个占位数据
同时在每次数据重置时都要确认当前数据的个数。

数组重置方法:

1
2
3
4
5
6
7
8
9
10
11
- (NSArray *)reCombineWithArray:(NSArray *)liveList {
NSMutableArray *listArray = [NSMutableArray arrayWithArray:liveList];
// 当前直播间列表中有多个直播间
ULDiscovery *firstObject = listArray.firstObject;
ULDiscovery *firstPlacehoder = [ULDiscovery parse:[firstObject toDictionary]];
ULDiscovery * lastObject = listArray.lastObject;
ULDiscovery *lastPlacehoder = [ULDiscovery parse:[lastObject toDictionary]];
[listArray addObject:firstPlacehoder];
[listArray insertObject:lastPlacehoder atIndex:0];
return [listArray copy];
}

之所以使用数据copy的方法是因为防止数组中数据对象相同导致在获取index时发生错位!

上面说过 因为数据是接口返回 那么可能存在下面两种情况:

  • 接口速度较快 在用户从倒数第二条数据滑动到倒数第一条数据前已经获取到数据了

这种情况下我们在滚动之前就已经操作了数据源 因此此时数据中已经包含了新请求的F G 因此在此向下滑动时可以正确的滚动到F和G这两条数据。

  • 接口速度较慢 在用户从倒数第二条主句滑动到倒数第一条数据时或者之后才获取到数据

这种情况下,我们滚动已经开始或者已经结束了,我们要滚动的位置已经根据之前的数据源(EABCDEA)确定了,当接口数据返回后数据源变成了(GABCDEFGA)我们可以发现除了占位数据外其他数据的index是没有改变的,所以这里不会影响数据返回前滚动的index数据的展示(index和数据还是一一对应的),只是新返回的数据需要再次滑动一遍之前的直播间(ABCDE)之后才有机会展示。

这样 我们就完成了动态添加数据后无限循环滚动视图的实现!

数据源删除

这种情况通常发生在滚动列表数据源中的某一条数据被用户动态删除,我们在查询列表数据详情时发现该数据状态为已删除时需要将这条数据从列表中删除。

这种情况下我们需要做

  • 1、删除这条无效数据
  • 2、重置当前数据源
  • 3、找到下一个要滚动到的视图并进行滚动
  • 4、滚动到指定位置

第一步和第二部我们在上面都说过这里不再赘述。我们在着重说一下第三步

下面先介绍找到下一个视图的方法和参数:

liveList:删除完无效数据之后重置的数组
index: 已删除的无效数据的index
destIndex: 根据滚动方向判断出的下一个要展示的视图的位置
scrollDown: 是否为向下滚动

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35

/// 滚动到下一个直播间的index
/// @param aDiscovery aDiscovery description
/// @param scrollDown scrollDown description
- (NSInteger)findNextLiveItems:(NSArray <ULDiscovery *> *)liveList index:(NSInteger)index destIndex:(NSInteger)destIndex scrollDown:(BOOL)scrollDown {
if (scrollDown && (destIndex >= self.liveListArray.count - 1)) {
// 如果是向下滚动且为最后一个直播间向第一个直播间滚动 第一个直播间被拉黑
// 需要滚动到第一个直播间的下一个直播间
// 数组正向遍历
ULDiscovery *destDis = [liveList ul_safeObjectAtIndex:index];
__block NSInteger disIndex = destIndex;
[liveList enumerateObjectsUsingBlock:^(ULDiscovery * _Nonnull obj, NSUInteger idx, BOOL * _Nonnull stop) {
if (obj.room.roomID == destDis.room.roomID) {
*stop = YES;
disIndex = idx;
}
}];
destIndex = disIndex+1;
self.currentIndex = 0;
self.lastRoomIndex = -1;
} else if (!scrollDown && destIndex <= 0) {
// 如果是向上滚动且为第一个直播间向最后一个直播间滚动 最后一个直播间被拉黑
ULDiscovery *destDis = [liveList ul_safeObjectAtIndex:index];
__block NSInteger disIndex = destIndex;
[liveList enumerateObjectsWithOptions:NSEnumerationReverse usingBlock:^(ULDiscovery * _Nonnull obj, NSUInteger idx, BOOL * _Nonnull stop) {
if (obj.room.roomID == destDis.room.roomID) {
disIndex = idx;
*stop = YES;
}
}];
destIndex = disIndex-1;
self.lastRoomIndex = self.liveListArray.count -1;
}
return destIndex;
}

滚动到指定位置方法

1
2
3
4
5
6
7
8
// 滚动到指定位置
dispatch_main_async_ulsafe(^{
if (destIndex > 0 && destIndex < self.liveListArray.count) {
[self.tableView scrollToRowAtIndexPath:[NSIndexPath indexPathForRow:destIndex inSection:0] atScrollPosition:UITableViewScrollPositionTop animated:NO];
} else {
[self.tableView scrollToRowAtIndexPath:[NSIndexPath indexPathForRow:0 inSection:0] atScrollPosition:UITableViewScrollPositionTop animated:NO];
}
});

这样我们就完成了 删除某条数据后循环滚动方案!!