一个 Excel 窗口冻结效果的实现

最近做了一个轮子 DQKFreezeWindowView (opens new window) ,这里我们一起探讨一下这个轮子中冻结效果的简单实现思路,也就是我的思考过程。 不废话,直接开始~

# 初步分析

既然是一个冻结的效果,那至少要用一个UIScrollView来做主要显示的部分。还是先来看几张已经实现了的:

日历 课程格子 Excel

在三者的使用当中,个人认为体验最好就是 MS 的 Excel 了,流畅、各方向都可以滑动。这里先用 Reveal 查看一下三者的布局,使用的 View 都是什么。

课程格子布局

可以看到某课程表 App 是选择了一个 UIScrollView 加两个 Bar (UIView)实现该功能。简单实用,显示少量视图尚可。

Excel 布局

为了显示更多数据/视图, Excel 就采用了三个UIScrollView。 这里有一些奇怪的现象引起了我的注意,为什么三者左边都不支持滑动?明明边栏视图都是在UIScrollView里,怎么想都是一个不合理的情况。所以我们的目标是这样的几个功能实现:

  • 主要视图支持多方位滚动
  • 边栏视图一样支持滚动
  • bounces效果实现
  • 像 Excel 一样支持更多数据显示

既然需要实现边缘的滑动,就要三个UIScrollView。为什么一个不行,因为当你滑动的时候,顶栏和侧栏最好是留在边缘的。

注意: 这里我考虑过使用UITableView或者UICollectionView实现,因为它已经很好的解决了delegatedataSource等众多问题。 XCMultiSortTableView (opens new window) 这个开源项目的方案是UITableView里面的UITableViewCell套一个UITableView,很好的实现边缘滚动问题。赞!但是并不能满足咱的要求,像 Excel 那样任意方向滚动。 博主没有想到用UITableView或者UICollectionView实现的方案所依如果你在这方面有什么好方案,快来和我交流。 关于以上几款 App 的研究有兴趣可以学习逆向相关问题 class-dump 和 IDA ,这里不再赘述。

# 开始实现

我们需要三个UIScrollView,首要问题就是实现同步滚动,也是整个问题的关键部分: 为了方便定位视图位置,先来自定义一个UIView,声明三个UIScrollView类,mainScrollViewsectionScrollViewrowScrollView。将该UIVIew添加到一个UIViewController中,三者在UIView位置如下图: 布局关系解释

开始实现同步滚动问题:

  • 普通情况的滚动 这个简单,使用- (void)scrollViewDidScroll:(nonnull UIScrollView *)scrollView加上- (void)setContentOffset:(CGPoint)contentOffset animated:(BOOL)animated;即可。比如 mainScrollView x 方向滚动多少, sectionScrollView x 方向滚动多少。
  • 滚动到边缘情况 这个稍微复杂一些,这里先谈一个触摸边缘滚动的情况,并且没有bounces,这种情况不涉及滑动越过边界问题。滚动 SectionScrollView 时, RowScrollView 不动,反之亦然。当前代码如下:
- (void)scrollViewDidScroll:(nonnull UIScrollView *)scrollView
{
    if ([scrollView isEqual:self.mainScrollView]) { //滚动 mainScrollView
        self.sectionScrollView.delegate = nil;
        self.rowScrollView.delegate = nil;
        [self.sectionScrollView setContentOffset:CGPointMake(self.mainScrollView.contentOffset.x, 0)];
        [self.rowScrollView setContentOffset:CGPointMake(0, self.mainScrollView.contentOffset.y)];
         self.sectionScrollView.delegate = self;
         self.rowScrollView.delegate = self;
    } else if ([scrollView isEqual:self.sectionScrollView]) {  // 滚动 sectionScrollView
        self.mainScrollView.delegate = nil;
        self.rowScrollView.delegate = nil;
        [self.mainScrollView setContentOffset:CGPointMake(self.sectionScrollView.contentOffset.x, self.mainScrollView.contentOffset.y)];
         self.mainScrollView.delegate = self;
         self.rowScrollView.delegate = self;
    } else if ([scrollView isEqual:self.rowScrollView]) {  // 滚动 rowScrollView
        self.mainScrollView.delegate = nil;
        self.sectionScrollView.delegate = nil;
        [self.mainScrollView setContentOffset:CGPointMake(self.mainScrollView.contentOffset.x, self.rowScrollView.contentOffset.y)];
         self.mainScrollView.delegate = self;
         self.sectionScrollView.delegate = self;
    }
}

诶?为什么我们要设置其他 scrollViewdelegate = nil。如果不设置,当视图滚动时,再次滑动会出现视图卡顿、移动混乱情况。

为什么? 因为要实现三个 scrollView 都支持滚动,那就都要设置其delegate属性,那么滚动时,三个视图都会委托这个函数来执行,如果不分开他们的滚动情况讨论,并在情况里面设置其他 scrollView delegatenil

其实到这里需要的功能已经实现了。但是我在使用的时候发现一个 Bug ,比如,我们现在滑动的是 mainScrollView ,视图停止滚动之前,去滑动 sectionScrollView 。诶!!!视图一下子就错位了,而且再滑动 mainScrollView 也不会同步了。这里我的理解是,- (void)scrollViewDidScroll:(nonnull UIScrollView *)scrollView,这个委托方法是在视图只要有滚动就会一直执行,那么其对应的delegate也是不停的在nilself之间不停的切换,滚动 mainScrollView 时,又去滚动另一个 scrollView ,将会执行self.mainScrollView.delegate = nil;, 同时执行另一个- (void)scrollViewDidScroll:(nonnull UIScrollView *)scrollView,出现错位,如果不去滑动其他 scrollViewself.mainScrollView.delegate就永远是nil了,没有了委托对象,自然不会继续同步。 两步解决问题,以滚动 mainScrollView 举例,第一步,在情况开始添加:

[self.sectionScrollView setContentOffset:self.sectionScrollView.contentOffset animated:NO];
[self.rowScrollView setContentOffset:self.rowScrollView.contentOffset animated:NO];

为什么这样做?理由很简单,立刻停止其他两个 scrollView 的滚动,其他情况的计算也就立即停止了。只计算一个滚动 mainScrollView 情况。 第二步,在委托方法最后重新设置回三者的delegate对象。这样一定不会在某次执行完出现nil情况了。

编译运行,Gut~随意滑动,三个位置任意滑动,随时切换滑动对象。 那么bounces效果怎么办?没有这个效果,滚动到边缘就会出现立即停止的不自然感觉。这里想过几种方案,不好的就不在这里谈了,浪费篇章,只提现在想到的最佳方案。先来考虑 Excel 的效果实现,你这么聪明,其实早就发现根本不需要改代码,对,没错。那么现在只需要考虑一起有 bounce 效果的情况,滚动到边缘时,看起来只是一个UIScrollView。这里需要考虑的就是 sectionScrollView 向下和 rowScrollView 向右移动的问题。当然这里也有多种方案:

  • 方案一:增加 View 的frame.size大小,这样视图就不会丢失了;
  • 方案二:改变 View 的位置。

采取方案二,方案一有视图重叠问题,不好不好。在原基础上增加代码,因为对于 sectionScrollView 视图水平滚动(contentOffset)已经完成,那么视图位置只需要垂直移动即可。还是滚动 mainScrollView 情况,直接贴代码:

if (self.bounceStyle == DQKFreezeWindowViewBounceStyleAll) {
    if (self.mainScrollView.contentOffset.y <= 0) {
        [self.sectionScrollView setFrame:CGRectMake(self.sectionScrollView.frame.origin.x, - self.mainScrollView.contentOffset.y, self.sectionScrollView.frame.size.width, self.sectionScrollView.frame.size.height)];
    }
    if (self.mainScrollView.contentOffset.x <= 0) {
        [self.rowScrollView setFrame:CGRectMake(- self.mainScrollView.contentOffset.x, self.rowScrollView.frame.origin.y, self.rowScrollView.frame.size.width, self.rowScrollView.frame.size.height)];
    }
}

最初的方案是考虑四个情况的(>>,><,<>,<<),同时还进行各种计算,现在发现,没有必要,sectionScrollViewy 坐标是 0 。那么滚动多少,移动多少就可以了。(取相反数,为什么?) 你应该注意到我在相关实现文件里还加了一个 signView ,也就是左上角的小视图。至于这个如何跟随着移动,请参考源文件或者留着你来思考,类似 sectionScrollViewrowScrollView

视图滚动问题解决!

效果已经实现,接下来的问题就是:

  • 数据/视图加载问题(也就是 Cell 的重用机制问题) 为了支持更好的使用内存,支持更多的数据滚动显示,那么如何计算那些视图在什么时候移除,又在什么时候加载,什么时候释放成了一个关键的难题。
  • 类似 UITableViewdelegatedateSource 的实现。 这里看过一些 GitHub 项目,发现其最终还是基于 UITableView 来实现的,失望ing 这两个问题,笔者实现的并不好,甚至很烂,同时限于篇幅,不再赘述这些问题。如果你在这里有什么好的经验或者见解,希望来与我分享,非常感激。当然,如果你对我对这两个问题的解决方案感兴趣,可以查看源码,随时私信我。欢迎。我的微博: @靛青K (opens new window)