LNCollectionView

引言

这篇文章没有任何公式,完全从代码层面讲述如何实现UICollectionView(不使用 UIScrollView);开源项目我放在了github上:LNCollectionView;因为我们没有使用任何原生提供的复合组件,在iOS上它只依赖UIView,在安卓上它只依赖ViewGroup。 一些仿真的物理算式我们在前面的文章中已经完整讲述了:

Decelerate&Bounce

Drag&PageEnable

在这篇文章中只会探讨如何将这些内容整合在一起设计一款可用的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的效果。

相关推荐
每天进步一大步40 分钟前
webSokect安卓和web适配的Bug 适用实时语音场景
android·前端·bug·web
avi91111 小时前
Unity开发哪里下载安卓Android-NDK-r21d,外加Android Studio打包实验
android·unity·游戏引擎
2301_780853861 小时前
Mysql高级
android·数据库·mysql
mubeibeinv4 小时前
项目搭建+姓名唯一性校验
android·okhttp
玖疯子5 小时前
如何按照详细的步骤进行DedeCMS的安装过程?
android·dedecms·织梦cms
王家视频教程图书馆5 小时前
请求go web后端接口 java安卓端播放视频
android·java·前端
mmsx6 小时前
android opencv导入进行编译
android·人工智能·opencv
truemi.737 小时前
flutter --no-color pub get 超时解决方法
android·flutter
wwwsmef18 小时前
android源码下载
android