引言
这篇文章没有任何公式,完全从代码层面讲述如何实现UICollectionView(不使用 UIScrollView);开源项目我放在了github上:LNCollectionView;因为我们没有使用任何原生提供的复合组件,在iOS上它只依赖UIView,在安卓上它只依赖ViewGroup。 一些仿真的物理算式我们在前面的文章中已经完整讲述了:
在这篇文章中只会探讨如何将这些内容整合在一起设计一款可用的UICollectionView;在你制作常规Feed或是视频流时,这不是一个实用的项目,UICollectionView非常好,LNCollectionView只是一个示例项目;但如果你想在个别页面篡改一些细节时,例如:停止速率,这个有用,因为它可以篡改所有细节;这个场景非常小众,一旦需要,使用UI组件的合法方式几乎没有办法实现,苹果不想让你篡改,所以没有公开接口提供给你,当然你也可以另辟蹊径,使用一点小手段,但是操作空间依然有限。
时钟
首先,我需要一个DisplayLink实现的clock,以便我处理一些来自用户手势的残余能量;如你所见,用户的手势松开时,它不会立即停止,所以这里需要一个Clock;这个Clock应该使用弱引用持有一些通知的接收者,不停地给他们发送时钟信号,晶振。
这个Clock的机制是:在每次加入一个监听者/移除一个监听者时,检查是否需要把那个Displaylink开起来:
ini
- (void)addObject:(id<LNScrollViewClockProtocol>)obj
{
if (![self.hashTable containsObject:obj]) {
[self.hashTable addObject:obj];
[self checkNeedStartOrStop];
}
}
- (void)removeObject:(id<LNScrollViewClockProtocol>)obj
{
if ([self.hashTable containsObject:obj]) {
[self.hashTable removeObject:obj];
[self checkNeedStartOrStop];
}
}
- (void)checkNeedStartOrStop
{
if (self.hashTable.count > 0) {
[self startOrResume];
} else {
[self stop];
}
}
start就会开启一个Displink,stop会关闭;开启时会不断代理出去一段时间。
职责分工
我在这里划分了三部分,从下到上分别是:
- Simulator:最底层的算数单元,一个Simulator包含了一个公式;一个Simulator会在被启动的一段时间内不断计算位移,把时间转化为位移:一个Simulator只有一个方向:x方向/y方向,一个Simulator只能一算一种效果,所以这里有DecelerateSimulator、BounceSimulator、PageSimulator、GragSimulator,他们都是用来做单一、单向的效果。
- Effect:用来聚合多个Simulator,组合每种Simulator的水平、竖直方向;有时候也会连接一些有关系的动画,比如:给Decelerate结尾连接一个Bounce。
- LNScrollView:使用effect更新contentOffset。
通过以上的方式组合成了UIScrollView;在这个流程中,UIScrollView可以使用代理数据源的方式接收到不同的Simulator以达到不同效果。
Simulator
列举一个简单的例子:
swift
@interface LNScrollViewDecelerateSimulator ()
@property (nonatomic, assign) CGFloat position;
@property (nonatomic, assign) CGFloat velocity;
@property (nonatomic, assign) CGFloat damping;
@property (nonatomic, assign) NSTimeInterval currentTime;
@end
@implementation LNScrollViewDecelerateSimulator
- (instancetype)initWithPosition:(CGFloat)position velocity:(CGFloat)velocity
{
self = [super init];
if (self) {
self.damping = 2.f;
self.position = position;
self.velocity = velocity;
}
return self;
}
- (void)accumulate:(NSTimeInterval)during
{
self.currentTime += during;
CGFloat v = self.velocity * exp(- self.damping * during);
CGFloat l = (-1.f/self.damping) * self.velocity * exp(-self.damping * during) - (-1.f/self.damping) * self.velocity;
if (self.velocity < 0.01) {
self.velocity = 0;
}
self.velocity = v;
self.position = self.position + l;
}
- (BOOL)isFinished
{
return fabs(self.velocity) < 0.01;
}
@end
有三个核心方法:
- init:开始
- accumulate:持续
- isFinished:结束
在初始化的时候会传递运动初始状态:运动初始位置和初始速度。 在运动过程中逐渐累积时间,把时间转化成位移和剩余速度,位移用于输出给外部、速度用于在下个周期继续计算。 结束的判断条件,使用一个速度的阈值判定是否识别为停止。
Effect
列举AutoEffect中的一部分代码,AutoEffect的主要工作是维护一个RestStatus,
objectivec
@interface LNScrollViewRestStatus()
@property (nonatomic, assign) CGPoint leadingPoint;
@property (nonatomic, assign) CGPoint trailingPoint;
@property (nonatomic, assign) CGPoint velocity;
@property (nonatomic, assign) CGPoint offset;
@property (nonatomic, assign) CGSize contentSize;
@property (nonatomic, assign) CGSize frameSize;
@property (nonatomic, assign) CGPoint startPosition;
@end
这个RestStatus,包含了环境信息和更新的信息。可以发现这些信息通常是矢量,这意味着他们标记的是数值和水平方向的,所以Effect就是把Simulator整合在一起。
首先,一个结束的手势会触发Effect开启一段新Effect:
ini
- (BOOL)startWithVelocity:(CGPoint)velocity {
[self finish];
[LNScrollViewClock.shareInstance addObject:self];
if (self.dataSource &&
[self.dataSource respondsToSelector: @selector(autoEffectGetFrameSize:)] &&
[self.dataSource respondsToSelector: @selector(autoEffectGetContentSize:)] &&
[self.dataSource respondsToSelector: @selector(autoEffectGetContentOffset:)]) {} else {
return NO;
}
CGSize contentSize = [self.dataSource autoEffectGetContentSize:self];
CGSize frameSize = [self.dataSource autoEffectGetFrameSize:self];
CGPoint contentOffset = [self.dataSource autoEffectGetContentOffset:self];
self.restStatus = [[LNScrollViewRestStatus alloc] init];
self.restStatus.velocity = velocity;
self.restStatus.contentSize = contentSize;
self.restStatus.frameSize = frameSize;
self.restStatus.startPosition = contentOffset;
CGFloat leadingX = 0;
CGFloat trailingX = contentSize.width - frameSize.width;
CGFloat leadingY = 0;
CGFloat trailingY = contentSize.height - frameSize.height;
self.restStatus.leadingPoint = CGPointMake(leadingX, leadingY);
self.restStatus.trailingPoint = CGPointMake(trailingX, trailingY);
self.restStatus.offset = contentOffset;
[self createHorizontalSimulatorIfNeeded];
[self createVerticalSimulatorIfNeeded];
return NO;
}
首先,它会先结束自己已经有的所有Simulator;然后重新创建一套新的restStatus和Simulator;create方法会根据具体情况的情况判定何种类型的Simulator,以竖直方向为例:
python
- (void)createVerticalSimulatorIfNeeded
{
if (self.restStatus.contentSize.height > self.restStatus.frameSize.height + LNScrollViewAutoEffectCommonTolerance) {
if (self.restStatus.startPosition.y < self.restStatus.leadingPoint.y - LNScrollViewAutoEffectCommonTolerance) {
[self createVerticalBounceSimulator:NO];
} else if (self.restStatus.startPosition.y > self.restStatus.trailingPoint.y + LNScrollViewAutoEffectCommonTolerance) {
[self createVerticalBounceSimulator:YES];
} else {
if (self.pageEnable) {
[self createVerticalPageSimulator];
} else {
[self createVerticalDecelerateSimulator];
}
}
}
}
超出contentSize范围会创建Bounce类型Simulator,在contentSize范围之内创建Decelerate类型Simulator,如果开启了PageEnable就会创建PageSimulator。
创建之后这个Simulator会在Clock的反馈中生效:
ini
- (void)scrollViewClockUpdateTimeInterval:(NSTimeInterval)time
{
BOOL didStatusChange = NO;
didStatusChange = [self updateVerticalDecelerateSimulator:time] || didStatusChange;
didStatusChange = [self updateVerticalBounceSimulator:time] || didStatusChange;
didStatusChange = [self updateVerticalPageSimulator:time] || didStatusChange;
didStatusChange = [self updateHorizontalDecelerateSimulator:time] || didStatusChange;
didStatusChange = [self updateHorizontalBounceSimulator:time] || didStatusChange;
didStatusChange = [self updateHorizontalPageSimulator:time] || didStatusChange;
if (didStatusChange && self.delegate && [self.delegate respondsToSelector: @selector(autoEffectStatusDidChange:)]) {
[self.delegate autoEffectStatusDidChange:self.restStatus];
}
[self checkFinished];
}
在这个Clock回调中,我们会使用Simulator更新数值,然后从Simulator中读取这些数值更新到restStatus,然后调用代理给LNScrollView来更新offset。
在每一次Clock回调结束的时候,都会检查每一个Simulator是否结束,如果结束了,则视为整个Simulator结束:
objectivec
- (BOOL)hasFinished
{
if (self.horizontalDecelerateSimulator &&
!self.horizontalDecelerateSimulator.isFinished) {
return NO;
}
if (self.horizontalBounceSimulator &&
!self.horizontalBounceSimulator.isFinished) {
return NO;
}
if (self.verticalDecelerateSimulator &&
!self.verticalDecelerateSimulator.isFinished) {
return NO;
}
if (self.verticalBounceSimulator &&
!self.verticalBounceSimulator.isFinished) {
return NO;
}
if (self.verticalPageSimulator &&
!self.verticalPageSimulator.isFinished) {
return NO;
}
if (self.horizontalPageSimulator &&
!self.horizontalPageSimulator.isFinished) {
return NO;
}
return YES;
}
LNScrollView
LNScrollView持有一个PanGesture,处理Gesture:
ini
- (void)dealWithPanGesture:(UIPanGestureRecognizer *)panGesture {
if (panGesture.state == UIGestureRecognizerStateBegan) {
[self.autoEffect finishForcely];
self.mode = LNScrollViewModeTracking;
CGPoint location = [self convertedRealLocation];
[self.gestureEffect startWithFrameSize:self.bounds.size contentSize:self.contentSize currentOffset:self.bounds.origin gesturePosition:location];
} else if (panGesture.state == UIGestureRecognizerStateChanged) {
[self.autoEffect finishForcely];
CGPoint location = [**self** convertedRealLocation];
[self.gestureEffect updateGestureLocation:location];
} else {
[self.gestureEffect finish];
CGPoint gestureVelocity = [panGesture velocityInView:self];
CGPoint viewVelocity = CGPointMake(-gestureVelocity.x, -gestureVelocity.y);
if ([self.autoEffect startWithVelocity:viewVelocity]) {
self.mode = LNScrollViewModeAuto;
} else {
self.mode = LNScrollViewModeDefault;
}
}
}
autoEffect:手势结束的后续动作(剩余的) gestureEffect:手势开始后的动作(跟手)
所以上面的逻辑就是:
- 手势开始时取消autoEffect,开启gestureEffect。
- 手势变更时更新gestureEffect。
- 手势结束时取消gestureEffect,开启autoEffect。 这里我们会把手势的瞬时速度传入gestureEffect,gestureEffect处理后,通过代理反馈到的LNScrollView中以更新offset。
LNCollectionView
这一层是从的ScrollView到LNCollectionView的封装;主要包含CollectionViewLayout、CollectionViewLayoutAttributes等布局结构;这部分完全借鉴了UICollectionView的实现:我们会在Layout.prepareLayout中会计算出全部的格子,在列表滚动的时候会使用格子创建出Attributes,LNCollectionView会使用Attributes创建Cell;同样的,他们也会在复用池中持久保存。 使用起来和UICollectionView非常类似,实现这些代理来定制布局和Cell:
objectivec
- (NSInteger)ln_numberOfSectionsInCollectionView:(LNCollectionView *)collectionView
{
return self.sectionCount;
}
- (NSInteger)ln_collectionView:(LNCollectionView *)collectionView numberOfItemsInSection:(NSInteger)section
{
return self.itemCount;
}
- (__kindof LNCollectionViewCell *)ln_collectionView:(LNCollectionView *)collectionView cellForItemAtIndexPath:(NSIndexPath *)indexPath
{
PowerLawDemoCell *cell = (PowerLawDemoCell *)[collectionView dequeueReusableCellWithReuseIdentifier:@"kPowerLawDemoCell" forIndexPath:indexPath];
cell.label.text = [NSString stringWithFormat:@"%@-%@", @(indexPath.section), @(indexPath.item)];
return cell;
}
- (CGSize)ln_collectionView:(LNCollectionView *)collectionView layout:(LNCollectionViewLayout *)collectionViewLayout sizeForItemAtIndexPath:(NSIndexPath *)indexPath
{
return CGSizeMake(100.f, 100.f);
}
结构图
这里附带一张LNScrollView工作机制的图片: ScrollView持有两个Effect:GestureEffect和AutoEffect,GestureEffect用来识别手势移动,在边界处处理一些位移转化问题;这个位移转化问题,我们之前已经阐释过了是一种近似于倒数的转化率;我们会在边界,做这个位移转化率;AutoEffect用来处理手势结束后的一些交互,减速或者弹性之类的,封装了各个方向的Simulator。 ScrollView->Effect->Simulator这个方向都是强持有/临时创建引用的;Simulator->Effect->ScrollView这个方向都是代理的方式,例如:
ini
- (LNScrollViewDecelerateSimulator *)ln_scrollViewHorizontalDecelerateSimulatorForPosition:(CGFloat)position velocity:(CGFloat)velocity {
LNScrollViewPowerLawDecelerateSimulator *simulator = [[LNScrollViewPowerLawDecelerateSimulator alloc] initWithPosition:position velocity:velocity k:2 n:1.2];
return simulator;
}
- (LNScrollViewDecelerateSimulator *)ln_scrollViewVerticalDecelerateSimulatorForPosition:(CGFloat)position velocity:(CGFloat)velocity {
LNScrollViewPowerLawDecelerateSimulator *simulator = [[LNScrollViewPowerLawDecelerateSimulator alloc] initWithPosition:position velocity:velocity k:2 n:1.2];
return simulator;
}
通过这样的代理方式返回一个Simulator,以实现自定义;例如:这个PowerLaw减速模型是一种幂率流体减速的模拟器,在这里返回替换的模拟器可以替换交互效果,后面我们会讲述如定制这个Simulator。
总结
本文讲述了LNCollectionView的分层设计和主要结构:LNCollectionView->LNScrollView->LNScrollViewEffect->LNScrollViewSimulator共四层结构以及设计思路,使用这种思路我们可以在任意平台上实现滚动列表;例如:我在项目中附带了LNCollectionView在安卓中的实现,这是一个继承自ViewGroup的容器,在安卓中有ScrollX和ScrollY可以设置,这个类似于iOS中的contentOffset,我们只要开启定时器不断更新这两个数值就能达到UIScrollView的效果。