iOS: 引导页 UIScrollView 自动布局(AutoLayout)详解

关键词: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;
      }
      

UIScrollView

UIScrollView 默认提供任意滚动功能,它有一个属性 pagingEnabled,设置为 YES 能直接提供翻页的功能,很神奇,与 UIPageControl 配合直接就是引导页嘛。

但 UIScrollView 的 contentSize 属性比较难搞,有很多坑,首先这玩意必须得设置,否则就滚不起来。纯代码进行设置还比较简单,如果用 AutoLayout 就会比较麻烦。其他 View 都用 AutoLayout,就 UIScrollView 用不了,不行,我吴小猫受不了这个委屈,所以选择麻烦的 AutoLayout,下面介绍如何用 AutoLayout 来搞定。

先介绍一个概念:

UIScrollView 的 contentSize 是由它与其 SubView 之间的约束计算出来的

翻译成大白话:父子关系决定父亲 contentSize 大小。

对普通的 UIView 来说,有这样一个定理:父子关系可以决定父亲大小。我们先来看这个定理是怎么回事。

这个定理说的是父亲的宽度和高度是可以由它儿子与它之间的约束来推导出来的。举个例子,创建一个 UIView 设置成黑色,然后在它内部添加一个 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 所需要的约束时提醒你,如下图所示:


Xcode 中的错误提示

先翻译一下第一段,这段说的是原则:

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 内部约束

水平方向:

  • [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 里就行了。

最终达到的效果:未来有新的引导页版本,只需要修改一下当前引导页版本号,显示过第一版引导页的老用户更新这个版本后就会看到第二版的引导页,当然,新安装的用户也能看到第二版的引导页。至于第一版和第二版一不一样暂时就不要想那么多了,因为这个逻辑并不需要修改存储的数据格式和使用的约定,需要的时候修改代码即可。

步骤:

  1. 定义 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;
}
  1. 定义表示当前引导页版本号的常量,为了留下历史记录,再定义一个枚举保存所有的版本号。
typedef NS_ENUM(NSInteger, PageGuideVersion) {
    PageGuideVersion_No = 0,
    PageGuideVersion_Feature1_Feature2,
};
static const int CURRENT_GUIDE_VERSION = PageGuideVersion_Feature1_Feature2;
  1. 在合适的地方检查引导页版本,如果有新版本就显示引导页,并在检查完毕后写入版本号。
- (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.

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 202,607评论 5 476
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,047评论 2 379
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 149,496评论 0 335
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,405评论 1 273
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,400评论 5 364
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,479评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,883评论 3 395
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,535评论 0 256
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,743评论 1 295
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,544评论 2 319
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,612评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,309评论 4 318
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,881评论 3 306
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,891评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,136评论 1 259
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 42,783评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,316评论 2 342

推荐阅读更多精彩内容

  • 一个天真的孩子躲在草丛里拿着硕大的放大镜,小心翼翼地对焦在那只可怜的蝴蝶身上. 那诡异般的画面展现在所有的孩子面前...
    简叶y阅读 212评论 0 1
  • 文‖水一空 20181225 数字,终于当了奴婢 1,有情。一支无理的拐杖 0,有欲。一面无性的镜子 1和0,两个...
    水一空阅读 177评论 0 8
  • 天天笑超哥“超,你啥时候带对象回家啊?”,没成想这事啊也落在了自己头上。 本来有我哥在我前面顶着,我小日子过得还挺...
    丿子木丨阅读 164评论 0 0