IOS仿寫知乎日報 - 主頁面
失業后發現自己的項目經驗太少了,除了公司的 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