关键词: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 关联,如下图所示:
-
使用 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.