转自我自己的 blog
失业后发现自己的项目经验太少了,除了公司的 App 和自己的游戏之外就几乎为零了,所以必须要增加自己的实战经验。之前写 UI 都是纯代码,为了熟悉 Storyboard,特地选择了知乎日报来练手。
在之前这个仿知乎日报的 iOS App 已经很出名了,我也是借鉴(就是抄)了部分的实现,图片和 API 则是完全照搬。当然我也有我自己的不同之处,我的 UI 是尽可能采用 Storyboard+xib 来实现,另外也在一些细节上更贴近正版知乎日报。
这篇主要讲一下首页的实现,以下是动图。
首页结构
首页主要由以下几部分组成:
- 顶部的图片轮播
- 下面的 TableView
- 顶部的其他小东西:展开侧边栏的按钮,刷新控件,「今日新闻」的标题,和一个随着 TableView 上滑出现的 View
上滑效果
先说一下 TableView 的实现。首先自定义一个 UITableViewCell,按照原版的把大小和位置设定好,这个不复杂,如下图:
接下来弄 TableView,这个 TableView 是和父视图同样大小的,也就是充满屏幕(注意,TableView 的父视图不是 HomeViewController 的 UIView,而是其下的 Subview,轮播视图以及其他控件都是放在这个 View 中的,至于为什么不直接放在 HomeViewController 的 View 里面,下一篇讲侧边栏实现的时候再解释……)。
在视觉上,第一感觉这个 TableView 好像应该是放在轮播图片的下面的(也就是 TableView 的 top 贴着轮播图片的 bottom),最开始我也是这样做的。
但是后来做上滑效果的时候才发现这样不行,因为上滑的时候需要 Cell 和轮播图片同时向上移动,这样 TableView 的 origin
就会改变,contentOffset
就不好计算了,而轮播图片的移动全靠这个 offset 来决定。
我也试过将 TableView 的初始 contentOffset
设为轮播图片的下面,但是滑上去就下不来了……所以,最后的解决办法是将 TableView 铺满屏幕,上面加一个和轮播图片同样高度的 Header,完美!
上面说过,上滑效果全靠 TableView 的 contentOffset
来实现。HomeViewController 要实现 UIScrollViewDelegate
中的 scrollViewDidScroll:
这个方法。 在这个方法里面,加入以下代码:
CGFloat offsetY = scrollView.contentOffset.y;
if (offsetY > 0) {
if (!self.topView) {
self.topView = [[UIView alloc] initWithFrame:CGRectMake(0, 0, kScreenWidth, 64)];
[self.homeView insertSubview:self.topView belowSubview:self.showSideMenuButton];
}
CGFloat alpha = offsetY / 64;
self.topView.backgroundColor = [UIColor colorWithRed:60.f / 255.f
green:198.f / 255.f
blue:253.f / 255.f
alpha:alpha];
self.carouselViewTop.constant = -offsetY;
}
这段代码做了这些:
- 首先判断 contentOffset.y,如果大于零那就是在向上滑动。
- 如果 topView 不存在的话,就新生成一个,并且 topView 的背景颜色随着滑动距离变化。
- 最后设置轮播图片距离父视图 top 的约束,这个变量是从 Storyboard 中拖过来的,将这个约束设为 -offsetY 就可以实现轮播图片和 Cell 一起向上滑动的效果了。
还需要注意的是,展示侧边栏的按钮,还有刷新控件和「今日新闻」的 UILabel,必须在层级上高于这个 topView,不然就会被 topView 盖住。
有一个小细节的地方,困扰了我好久,就是 TableView 的第一个 Cell 和上面的轮播图片始终有一段距离。最后各种尝试和搜索后才找到解决方法:在 Storyboard 中选中 HomeViewController,在 Attributes Inspector 中把 Adjust Scroll View Insets
这个选项勾掉。
图片轮播
这部分在实现思路上基本完全借鉴了上面提及的那个仿作,整个控件的容器是一个 UIScrollView
,里面并排摆放所有的图片,还有一个 UIPageControl
来显示对应的索引。
自定义一个 BannerView,用来显示每一个轮播的图片以及标题。上面的容器里装的就是这个 View。
重要的轮播逻辑是这样的:通过 API 获取的轮播个数是5个,但是容器中的 View 是7个(5+2)。这一排的 BannerView 按照序号是这样排列的,5-1-2-3-4-5-1,也就是把第一个和最后一个复制一份添加到数组的尾和头。
而 ScrollView 的初始 offset 是数组的第二个(也就是序号为1的)。这样,1在右划的时候会在左面显示5,5在左划的时候会显示1。如果 ScrollView 的 contentOffset 停留在数组的第一个(5),那么就把 contentOffset 设为数组的第6个(正确顺序的5)。同理,如果 ScrollView 的 contentOffset 停留在数组的最后一个(1),那么就把 contentOffset 设为数组的第2个(正确顺序的1)。这样就实现了一个可以无限循环的轮播。
相关代码如下:
-(void)scrollViewDidScroll:(UIScrollView *)scrollView {
CGFloat offsetX = scrollView.contentOffset.x;
if (offsetX == 6 * kScreenWidth) {
_scrollView.contentOffset = CGPointMake(kScreenWidth, 0);
_pageControl.currentPage = 0;
} else if (offsetX == 0) {
_scrollView.contentOffset = CGPointMake(5 * kScreenWidth, 0);
_pageControl.currentPage = 4;
} else {
_pageControl.currentPage = offsetX/kScreenWidth - 1;
}
}
具体运行时的效果如下:
刷新动画
这里有两部分内容,一是刷新控件的实现,二是刷新控件的控制。
刷新控件的是由两部分组成的,一个 UIActivityIndicatorView
和由 CAShapeLayer
绘制的圆环。
定义一个 RefreshView,初始化中加入以下代码:
- (void)customInit {
_indicatorView = [[UIActivityIndicatorView alloc]initWithFrame:self.bounds];
_grayCircleShapeLayer = [CAShapeLayer layer];
_grayCircleShapeLayer.lineWidth = 2.f;
_grayCircleShapeLayer.strokeColor = [UIColor grayColor].CGColor;
_grayCircleShapeLayer.fillColor = [UIColor clearColor].CGColor;
_grayCircleShapeLayer.opacity = 0;
_grayCircleShapeLayer.path = [UIBezierPath bezierPathWithOvalInRect:self.bounds].CGPath;
_whiteCircleShapeLayer = [CAShapeLayer layer];
_whiteCircleShapeLayer.lineWidth = 2.f;
_whiteCircleShapeLayer.strokeColor = [UIColor whiteColor].CGColor;
_whiteCircleShapeLayer.fillColor = [UIColor clearColor].CGColor;
_whiteCircleShapeLayer.opacity = 0;
_whiteCircleShapeLayer.path = [UIBezierPath bezierPathWithArcCenter:CGPointMake(self.width/2, self.width/2)
radius:self.width/2
startAngle:M_PI_2 endAngle:M_PI * 5 / 2
clockwise:YES].CGPath;
_whiteCircleShapeLayer.strokeEnd = 0;
[self addSubview:_indicatorView];
[self.layer addSublayer:_grayCircleShapeLayer];
[self.layer addSublayer:_whiteCircleShapeLayer];
}
圆环由一个灰色的背景圆环和一个表示进度的白色圆弧组成,下拉过程中更新白色圆弧的长度,到指定位置后,整个圆环消失,开始 UIActivityIndicatorView
的动画。
更新圆环进度的代码如下:
-(void)updateProgress:(CGFloat)progress {
if (progress <= 0) {
_whiteCircleShapeLayer.opacity = 0;
_grayCircleShapeLayer.opacity = 0;
} else {
_whiteCircleShapeLayer.opacity = 1;
_grayCircleShapeLayer.opacity = 1;
}
if (progress > 1) {
progress = 1;
}
_whiteCircleShapeLayer.strokeEnd = progress;
}
对刷新控件的控制其实和上滑的控制一样,也在 HomeViewController 中的 scrollViewDidScroll:
中,这部分逻辑就是 offsetY < 0 的那一部分。
self.carouselViewHeight.constant = 220 - offsetY;
if (offsetY <= -kRefreshOffsetY * 1.5) {
self.tableView.contentOffset = CGPointMake(0, -kRefreshOffsetY * 1.5);
} else if (offsetY <= 0 && offsetY >= -kRefreshOffsetY * 1.5) {
if (self.isRefreshing) {
[self.refreshView updateProgress:0];
} else {
[self.refreshView updateProgress:-offsetY / kRefreshOffsetY];
}
}
if (offsetY < -kRefreshOffsetY && !scrollView.isDragging) {
[self.refreshView startAnimation];
self.isRefreshing = YES;
dispatch_after(
dispatch_time(DISPATCH_TIME_NOW, (int64_t)(2 * NSEC_PER_SEC)),
dispatch_get_main_queue(), ^{
[self.refreshView stopAnimation];
self.isRefreshing = NO;
});
}
这段代码的逻辑有:
- 下拉时增加轮播图片的高度。
- TableView 不是无限下拉的,只能下拉到一个指定的位置,超过的话,TableView 就不再下滑了。
- 下拉一段后再上滑,如果进入了刷新状态,不显示圆环;如果没进入刷新状态,那么就根据 下拉距离/下拉阈值 来更新圆环进度。
- 如果下拉距离达到了阈值并且松手了(没有拖动),那么就进入刷新状态。我这里做了个2秒刷新时间。
遗留
- 目前这个主页只做了展示,点击没有任何效果。
- TableView 滑上再滑下的时候,topView 不会完全消失,可能会有淡淡地残留,这点还没有优化。
- 原版的轮播图片底部和顶部有黑色阴影的渐变,这样在纯白的图片下,按钮和文字标题都可以清晰显示出来,这点我也没做。
另外,代码请戳 github