關鍵詞:iOS、引導頁、UIScrollView、AutoLayout、自動布局、OC、Objective-C
開屏引導頁是app常用的一種引導頁,即第一次打開app后顯示給用戶的幾個左右滑動的頁面,用來提醒用戶這個版本有什么新東西。
由于 UIScrollView 和 UIPageControl 配合能完美的實現引導頁的功能,因此這個任務并不算太難。本文完整介紹了如何創建一個典型的引導頁,并重點講解了如何使用 AutoLayout 來設置 UIScrollView。
需求
- 第一次打開app的時候顯示,顯示過之后再次打開就不再顯示了。
- 更新引導頁,更新第二版或者更多,這是個復雜問題,當前沒有需求,可以簡單分析一下:
- 最簡單的情況不要第二版引導頁,就不用管了這個需求了,能滿足第一條需求就行。
- 對之前顯示過第一版引導頁的老用戶是否顯示第二版引導頁
- 直接安裝第二版的新用戶肯定要顯示引導頁,如果上一條老用戶需要顯示第二版,那么問題來了,對新用戶顯示的與老用戶顯示的是否一樣。我覺得顯示的一樣即可,不需要這么復雜……萬一你家產品經理抽風,也可以有個心理準備哈哈。
-
有圖有真相,界面如下,其實沒啥特別的:
引導頁 - 能左右滑動也能點“下一步”按鈕進入到下一頁,要有動畫。
- 最后一頁的時候“下一步”按鈕變成“開始”,與“跳過”的功能相同,即結束引導。
界面實現
自定義組合View
先來實現呈現內容的界面,這個界面包含一張圖、兩段文字。因為有 3 個圖文界面,直接寫死顯然不行,因此使用一個自定義View,通過屬性來設置圖片和文字。
新建OC類、新建 xib 界面,起一樣的名字 GuideView.h、GuideView.m、GuideView.xib。
-
打開 GuideView.xib 關聯代碼中的 GuideView 類,使用 File's Owner 關聯,如下圖所示:
File's Owner 關聯 OC 類 -
使用 AutoLayout 布局一個 UIImageView、兩個 UILabel,這里不深入講解 AutoLayout 了,只列出幾個需要注意的點。
- 適配不同機型,設計師給出的設計方案通常都是一個固定的界面上的尺寸和位置信息,有經驗的或許能主動告訴你在多設備上的顯示規則,如果沒有作為程序猿也是要自己問清楚的。
- 這個引導頁設計圖是按照 iPhone8 尺寸設計的,主要適配規則是上下居中,因為主要影響適配的是比較高的 X 系列設備。也就是說在比較高的 X 設備上這些圖文內容要盡量上下居中,不能將內容都堆疊在頂部或者底部。
- 從實現上來講,要達到上下居中,AutoLayout 中的 Constraint 要定義成依賴 Center 的,比如
Image View.centerY = Safe Area.centerY - 116
。 - 多行文字的 UILabel 可以選擇約束 leading + trailing,也可以選擇約束 width,這就看具體需求是需要邊距固定還是寬度固定了,同樣如果不清楚也要向設計師問清楚。
-
實現 OC 類
- 在 GuideView.h 中定義接口,這里定義幾個屬性
@property (nonatomic, copy) NSString *imageName; @property (nonatomic, copy) NSString *title; @property (nonatomic, copy) NSString *subtitle;
- 在 GuideView.m 中實現 setter 方法,沒什么復雜的內容,直接給對應的 UIImageView 或 UILabel 設置內容。
- (void)setImageName:(NSString *)imageName { _imageName = [imageName copy]; self.imageView.image = [UIImage imageNamed:_imageName]; } - (void)setTitle:(NSString *)title { _title = [title copy]; self.titleLabel.text = _title; } - (void)setSubtitle:(NSString *)subtitle { _subtitle = [subtitle copy]; self.subtitleLabel.text = _subtitle; }
- 還要重寫
- (instancetype)initWithCoder:(NSCoder *)aDecoder
方法。注意owner
參數在用 File's Owner 關聯的情況下應該用self
- (instancetype)initWithCoder:(NSCoder *)aDecoder { self = [super initWithCoder:aDecoder]; if (self) { UIView* contentView = [NSBundle.mainBundle loadNibNamed:@"GuideView" owner:self options:nil].firstObject; contentView.frame = self.bounds; [self addSubview:contentView]; } return self; }
- 在 GuideView.h 中定義接口,這里定義幾個屬性
UIScrollView
UIScrollView 默認提供任意滾動功能,它有一個屬性 pagingEnabled,設置為 YES 能直接提供翻頁的功能,很神奇,與 UIPageControl 配合直接就是引導頁嘛。
但 UIScrollView 的 contentSize 屬性比較難搞,有很多坑,首先這玩意必須得設置,否則就滾不起來。純代碼進行設置還比較簡單,如果用 AutoLayout 就會比較麻煩。其他 View 都用 AutoLayout,就 UIScrollView 用不了,不行,我吳小貓受不了這個委屈,所以選擇麻煩的 AutoLayout,下面介紹如何用 AutoLayout 來搞定。
先介紹一個概念:
UIScrollView 的 contentSize 是由它與其 SubView 之間的約束計算出來的
翻譯成大白話:父子關系決定父親 contentSize 大小。
對普通的 UIView 來說,有這樣一個定理:父子關系可以決定父親大小。我們先來看這個定理是怎么回事。
這個定理說的是父親的寬度和高度是可以由它兒子與它之間的約束來推導出來的。舉個例子,創建一個 UIView 設置成黑色,然后在它內部添加一個 UIView 設置成橙色,如圖:
然后僅添加如圖所示的約束(不添加其他任何約束),可以看到內層 UIView 有固定的大小 200×100,然后對四條邊分別添加一個相對外層 UIView 的 margin 48。這里并沒有指定外層 UIView 的大小,但根據這幾條約束 AutoLayout 能夠推斷出外層 UIView 的大小,即 (200 + 48 + 48) × (100 + 48 + 48) = 296×196??梢栽?Xcode 中實驗一下,改變內層 UIView 的大小,外層 UIView 的大小也會隨之改變。
這就是父子關系可以決定父親大小的含義。再說回 UIScrollView,可以這樣理解,在剛才的例子中,如果把外層 UIView 替換成 UIScrollView,那么,原來那些父子關系約束能推斷出的尺寸大小,就從外層 UIView 的寬度和高度替換成了 UIScrollView 的 contentSize。
其實 Xcode 會在你沒有設置好 contentSize 所需要的約束時提醒你,如下圖所示:
先翻譯一下第一段,這段說的是原則:
UIScrollView 的可以滾動范圍(contentSize)是由它的 subview 的約束自動計算出來的。要計算出正確的可滾動范圍,UIScrollView 的四個邊(leading, trailing, top, bottom)相關的約束必須全部定義。
下面這段說的是直觀的修改方法(其實也不那么直觀):
確保有一系列的連續的約束,形成一條線從 UIScrollView 的 leading(或 top)連到 UIScrollView 的 trailing(或 bottom),并貫穿所有的 subview。
第二段修改方法也可以翻譯成一句大白話:把一顆顆的山楂穿成一串糖葫蘆,就知道應該用多大的盒子裝了。對引導頁來說,并不適合講解這個問題,因為引導頁中的 UIScrollView 的 contentSize 寬度是屏幕寬度的 n 倍,會超出屏幕很多不是很直觀。這里再舉個小例子,創建一個 UIScrollView,里面添加 4 個 UIImageView,如圖:
圖中左邊的約束和右側的連線用字母標識了對應關系??梢钥吹?Xcode 不會直接給你顯示出一條明顯的線,更多地還是要靠我們自己清晰的思路和風騷的操作……由于 UIScrollView 的 contentSize 總是要比它本身的寬高要大(至少一個維度大),所以在 Xcode 中這條線應該總是超出可顯示范圍,這給操作也帶來了麻煩,我們應該依靠清晰的思路來指導風騷的操作來創建約束……
這是三張引導頁的約束:
水平方向:
- [UIScrollView] --- [1] --- [2] --- [3] --- [UIScrollView]
-
Guide View 1.leading = leading
[UIScrollView] --- [Guide View 1] -
Guide View 2.leading = Guide View 1.trailing
[Guide View 1] --- [Guide View 2] -
Guide View 3.leading = Guide View 2.trailing
[Guide View 2] --- [Guide View 3] -
trailing = Guide View 3.trailing
[Guide View 3] --- [UIScrollView]
豎直方向:
由于引導頁是左右滾動,上下不應該滾動,設置 contentSize.height = 0 即可:
Guide View 1.top = bottom
Guide View 1.top = top
至此,UIScrollView 的 contentSize 約束就設置好了。
View Controller
這個引導頁是一個單獨的 View Controller,比較獨立,只需考慮引導頁相關功能,實現起來也很簡單。簡單列一下,具有以下幾部分代碼:
- 填充數據。單個引導頁面使用的自定義 View,無法在 storyboard 中設置它的屬性,只好在代碼里設置了。
- 定義需要的數據,在
viewDidLoad
中定義,這里將其設置為 C 數組,沒別的原因,定義 NSArray 寫的字太多…… 數據包括 GuideView 的三個屬性,所以最后是 3 個數組,長度都是 3。 - 通過 UIScrollView 的實例 scrollView 來獲取所有的 GuideView:
self.scrollView.subviews
,并將數據一一設置。 - 如果定義數據的數量和 GuideView 的數量不一致怎么辦,這個開發過程中容易犯錯誤,用
NSAssert
做個保護。
- (void)initGuidePageData { NSInteger pageCount = self.pageCount; NSString* imageNames[] = {@"guide_image_1", @"guide_image_2", @"guide_image_3"}; NSString* titles[] = {kStringGuideTitle1, kStringGuideTitle2, kStringGuideTitle3}; NSString* subtitles[] = {kStringGuideSubtitle1, kStringGuideSubtitle2, kStringGuideSubtitle3}; NSAssert(pageCount == CArrayLength(imageNames), @"image count does not match page count"); NSAssert(pageCount == CArrayLength(titles), @"title count does not match page count"); NSAssert(pageCount == CArrayLength(subtitles), @"subtitle count does not match page count"); for (int i = 0; i < pageCount; i++) { GuideView *guideView = self.scrollView.subviews[i]; guideView.imageName = imageNames[i]; guideView.title = titles[i]; guideView.subtitle = subtitles[i]; } } - (NSInteger)pageCount { return self.scrollView.subviews.count; }
- 定義需要的數據,在
- UIScrollViewDelegate
- 這個協議用來捕獲 UIScrollView 相關的回調,直接讓 View Controller 遵守 UIScrollViewDelegate 協議。
- (void)viewDidLoad { ... self.scrollView.delegate = self; ... }
- 需要實現滾動結束回調的方法來更新 UIPageControl,很遺憾沒有統一的回調,分為兩種情況。
// 直接滾動 UIScrollView 結束時回調 - (void)scrollViewDidEndDecelerating:(UIScrollView *)scrollView { [self updateIndex]; } // 代碼觸發滾動結束時回調 - (void)scrollViewDidEndScrollingAnimation:(UIScrollView *)scrollView { [self updateIndex]; }
- UIPageControl
- 指示當前頁面的小圓點們,用法很簡單只需要設置
pageControl
屬性。
- (void)updateIndex { int index = [self currentPage]; self.pageControl.currentPage = index; [self.nextButton setTitle:(index < self.pageCount - 1 ? kStringNext : kStringStart) forState:UIControlStateNormal]; }
- 指示當前頁面的小圓點們,用法很簡單只需要設置
- 按鈕事件
// “下一步”按鈕,區分一下最后一頁的時候的行為 - (IBAction)onNext:(id)sender { int index = [self currentPage]; if (index < self.pageCount - 1) { CGFloat x = (index + 1) * self.scrollView.frame.size.width; [self.scrollView setContentOffset:CGPointMake(x, 0) animated:YES]; } else { [self onDone]; } } // “跳過”按鈕 - (IBAction)onSkip:(id)sender { [self onDone]; } - (void)onDone { [self.navigationController popViewControllerAnimated:NO]; }
存儲引導頁已讀狀態
前面說過,為了以后第二個版本的引導頁考慮,不能簡單保存個布爾狀態,而是要引入一個版本機制。雖然未來的需求是不確定的,但保存布爾狀態沒有任何擴展性,需要另一種更靈活的記錄方式。
簡單地說就是定義一個引導頁版本號,這個版本號與應用的版本號并沒有對應關系,因為應用版本號更新并不意味著引導頁也更新了。因此保存一個引導頁版本號的整數到 UserDefaults 里就行了。
最終達到的效果:未來有新的引導頁版本,只需要修改一下當前引導頁版本號,顯示過第一版引導頁的老用戶更新這個版本后就會看到第二版的引導頁,當然,新安裝的用戶也能看到第二版的引導頁。至于第一版和第二版一不一樣暫時就不要想那么多了,因為這個邏輯并不需要修改存儲的數據格式和使用的約定,需要的時候修改代碼即可。
步驟:
- 定義 UserDefaults 一對兒方法
// UserDefaultsUtils.m
+ (void)setLastIntegerVersion:(int)version {
NSUserDefaults *defaults = [NSUserDefaults standardUserDefaults];
[defaults setInteger:version forKey:KEY_LAST_INTEGER_VERSION];
[defaults synchronize];
}
+ (int)lastIntegerVersion {
NSUserDefaults *defaults = [NSUserDefaults standardUserDefaults];
NSInteger v = [defaults integerForKey:KEY_LAST_INTEGER_VERSION];
return (int)v;
}
- 定義表示當前引導頁版本號的常量,為了留下歷史記錄,再定義一個枚舉保存所有的版本號。
typedef NS_ENUM(NSInteger, PageGuideVersion) {
PageGuideVersion_No = 0,
PageGuideVersion_Feature1_Feature2,
};
static const int CURRENT_GUIDE_VERSION = PageGuideVersion_Feature1_Feature2;
- 在合適的地方檢查引導頁版本,如果有新版本就顯示引導頁,并在檢查完畢后寫入版本號。
- (void)theMethodThatDecidesGuidePageToShow {
...
if ([self checkGuideVersionUpdate]) {
return;
}
...
}
- (BOOL)checkGuideVersionUpdate {
int lastVersion = [UserDefaultsUtils lastGuideVersion];
int currentVersion = CURRENT_GUIDE_VERSION;
if (currentVersion > lastVersion) {
[UserDefaultsUtils setLastGuideVersion:currentVersion];
return [self onGuideVersionUpdateFromOldVersion:lastVersion newVersion:currentVersion];
}
return NO;
}
- (BOOL)onGuideVersionUpdateFromOldVersion:(int)oldVersion newVersion:(int)newVersion {
[self.navigationController pushViewController:[self guideViewController] animated:NO];
return YES;
}
- (UIViewController *)guideViewController {
UIStoryboard *story = [UIStoryboard storyboardWithName:@"Main" bundle:[NSBundle mainBundle]];
UIViewController *guideViewController = [story instantiateViewControllerWithIdentifier:@"guide"];
return guideViewController;
}
THE END.