关键词:iOS、引导页、UIScrollView、AutoLayout、自动布局、OC、Objective-C
开屏引导页是app常用的一种引导页,即第一次打开app后显示给用户的几个左右滑动的页面,用来提醒用户这个版本有什么新东西。
由于 UIScrollView 和 UIPageControl 配合能完美的实现引导页的功能,因此这个任务并不算太难。本文完整介绍了如何创建一个典型的引导页,并重点讲解了如何使用 AutoLayout 来设置 UIScrollView。
先来实现呈现内容的界面,这个界面包含一张图、两段文字。因为有 3 个图文界面,直接写死显然不行,因此使用一个自定义View,通过属性来设置图片和文字。
新建OC类、新建 xib 界面,起一样的名字 GuideView.h、GuideView.m、GuideView.xib。
打开 GuideView.xib 关联代码中的 GuideView 类,使用 File’s Owner 关联,如下图所示:
使用 AutoLayout 布局一个 UIImageView、两个 UILabel,这里不深入讲解 AutoLayout 了,只列出几个需要注意的点。
Image View.centerY = Safe Area.centerY - 116
。实现 OC 类
@property (nonatomic, copy) NSString *imageName;
@property (nonatomic, copy) NSString *title;
@property (nonatomic, copy) NSString *subtitle;
- (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;
}
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 中这条线应该总是超出可显示范围,这给操作也带来了麻烦,我们应该依靠清晰的思路来指导风骚的操作来创建约束……
水平方向:
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,无法在 storyboard 中设置它的属性,只好在代码里设置了。
viewDidLoad
中定义,这里将其设置为 C 数组,没别的原因,定义 NSArray 写的字太多…… 数据包括 GuideView 的三个属性,所以最后是 3 个数组,长度都是 3。self.scrollView.subviews
,并将数据一一设置。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
- (void)viewDidLoad {
...
self.scrollView.delegate = self;
...
}
// 直接滚动 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 里就行了。
最终达到的效果:未来有新的引导页版本,只需要修改一下当前引导页版本号,显示过第一版引导页的老用户更新这个版本后就会看到第二版的引导页,当然,新安装的用户也能看到第二版的引导页。至于第一版和第二版一不一样暂时就不要想那么多了,因为这个逻辑并不需要修改存储的数据格式和使用的约定,需要的时候修改代码即可。
步骤:
// 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.