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的效果。

相关推荐
雪铃儿41 分钟前
Shorebird 之外,Flutter Android 热更新还有什么选择
android·前端
sweet丶1 小时前
现有基础上增加设备生物识别登录的一个技术方案
ios
张筱竼2 小时前
Android开发中的MVC、MVP与MVVM详解
android
阿巴斯甜4 小时前
必看4
android
Carson带你学Android4 小时前
Android 17 最后一个 Beta 发布,7 件事必须现在做
android·ai编程
ooseabiscuit5 小时前
Laravel 9.x重磅升级:PHP8新特性全解析
android
嵌入式×边缘AI:打怪升级日志5 小时前
转换模块(十二):实现 RGB 转 RGB + 项目整合与上机实验
开发语言·ios·swift
唐诺5 小时前
IOS学习路线计划
ios
帅次5 小时前
深入 MaterialTheme:掌握 ColorScheme 与 Typography 的设计核心
android·kotlin·gradle·android jetpack·compose