背景

有一种现象,App设计者觉得理所当然的操作方式,却常常被用户所忽视,为了防止这种现象发生,就要为App设计一个帮助,一种低成本的方案是将帮助文档写成HTML然后展示给用户,这样的方式常常不能带来好的效果,一种较好的方式是高亮用户应该点击的区域,对其他部分进行遮盖,并用说明文字提醒用户,如下图所示。点击这里观看动画演示

下载

框架SGUserGuide已经上传到github,点击前去github下载,欢迎Star!

关键

要实现这种引导,关键问题有二,一是如何拿到允许交互的控件,二是如何处理引导步骤的推进关系。

对于第一个问题,可以通过keyPath解决,keyPath的强大之处在于可以用点语法拿到更深层的私有,例如我们的ViewController有一个私有属性topView,而topView又有私有属性topButton,那么我们使用topView.topButton即可从ViewController中拿到控件topButton而丝毫不破坏其封装性。

对于第二个问题,可以通过AOP编程解决。我们知道大部分的交互都涉及页面切换,例如上图点击按钮后进入编辑页面,因此页面的切换可以作为一个“切面”,我们通过这个切面来处理大部分的引导步骤推进。我们可以通过Method Swizzling来拦截所有的viewWillAppear:方法,并处理引导步骤的判断与推进,需要注意的是还有一些不涉及页面切换的引导步骤,则需要在适当的地方手动推进。

实现

描述用户引导步骤的类的设计

为了描述一个引导步骤,首先要判断当前页面是否应该被引导,通过ViewController的类型来判断;其次需要的是可交互控件,通过keyPath来寻找;除此之外,还需要对用户的提示信息,这个类的具体设计如下:

@interface SGGuideNode : NSObject

@property (nonatomic, assign) Class controllerClass;
@property (nonatomic, strong) NSString *permitViewPath;
@property (nonatomic, copy) NSString *message;
@property (nonatomic, assign) BOOL reverse; + (instancetype)nodeWithController:(Class)controller permitViewPath:(NSString *)permitViewPath message:(NSString *)message reverse:(BOOL)reverse;
+ (instancetype)endNodeWithController:(Class)controller; @end

其中reverse是一个用于反转遮盖与可交互控件的属性,用于类似于“进行一项除去退出以外的操作”的情景。

通过两个类方法可快速的创建一个步骤结点,endNode作为结束结点,用于判断用户引导是否结束。

遮盖层视图设计

拦截交互事件

遮盖层视图需要盖住界面,并且在可交互区域“挖洞”,要实现这种功能,可以通过pointInside:withEvent:方法处理点击事件,对于落在洞外的点交给遮盖层处理,也就是返回YES,这样就保证了原来的交互事件被拦截。

其中permitRect为允许交互的视图的

- (BOOL)pointInside:(CGPoint)point withEvent:(UIEvent *)event {
BOOL ret = !CGRectContainsPoint(self.permitRect, point);
if (self.node.reverse) {
ret = !ret;
}
return ret;
}

绘制遮盖区域与允许点击区域

处理完了点击事件,我们只需要通过drawRect:在遮盖区绘制透明的灰色,在允许交互区域绘制透明色即可做出预想的效果。

首先我们要定义出maskColor和holeColor,然后先对整个遮盖层视图填充maskColor,再对允许交互区填充holeColor。

- (void)drawRect:(CGRect)rect {
// 省略maskColor、holeColor的定义与赋值代码
[maskColor setFill];
UIRectFill(rect);
// 省略允许点击区域permitRect的计算代码
[holeColor setFill];
UIRectFill(self.permitRect);
}

计算说明文字的区域

接下来一个问题是提示文字的位置,提示文字应该紧贴可交互区域,并且应该尽可能拥有更多的空间,因此我们需要计算可交互区域四周的面积,并选择一块最大的区域。

添加遮盖层

最最关键的问题是遮盖层应该添加到谁的view身上,由于在触发一个引导步骤时已经拿到了当前显示的视图控制器(引导步骤的触发通过拦截viewWillAppear:实现,因此可以拿到视图控制器对象),因此添加变得十分简单。

不要简单的认为将遮盖层添加到视图控制器的view即可,因为视图控制器可能有NavigationController或者TabbarController包裹,如果只是添加到视图控制器的view无法盖住顶部和底部区域

基于这个考虑,我们按照tabBarController.view>navigationController.view>viewController.view的优先级来添加遮盖层。

- (void)showInViewController:(UIViewController *)viewController {
// 每次显示前,保证显示中的遮盖层已经被移除,通过removeFromSuperview移除。
[self hide];
self.permitView = [viewController valueForKeyPath:self.node.permitViewPath];
self.messageLabel.text = self.node.message;
if (viewController.tabBarController) {
[viewController.tabBarController.view addSubview:self];
}else if (viewController.navigationController) {
[viewController.navigationController.view addSubview:self];
} else {
[viewController.view addSubview:self];
}
self.frame = self.superview.frame;
[self setNeedsDisplay];
}

这里包含了对步骤结点的解析,注意遮盖的尺寸与要盖住的视图大小一致,最后一句会触发drawRect:根据最新的结点解析数据绘制遮盖层与允许交互层。

移除遮盖层

移除遮盖层,只需要调用removeFromSuperview即可。

- (void)hide {
[self removeFromSuperview];
}

调度器的设计

调度器类的设计

要实现步骤的切换,需要一个全局调度器,它接收切面通知或者用户的手动通知来对步骤进行判断与切换。所有的步骤结点都被以数组的形式保存到调度器中,调度器通过游标cur来判断当前进行到的步骤。

为了使用方便,编程者只需要将结点数组传递给调度器,调度器便会自动开始处理步骤的判断与切换,例如下面的代码:

- (void)setupGuide {
SGGuideDispatcher *dp = [SGGuideDispatcher sharedDispatcher];
dp.nodes = @[
[SGGuideNode nodeWithController:[FirstViewController class] permitViewPath:@"addBtn" message:@"Please Click The Add Button And Choose Yes From the Alert." reverse:NO],
[SGGuideNode nodeWithController:[FirstViewController class] permitViewPath:@"wrap.innerView" message:@"Please Click the Info Button" reverse:NO],
[SGGuideNode nodeWithController:[SecondViewController class] permitViewPath:@"tabBarController.tabBar" message:@"Please Change To Third Page" reverse:NO],
[SGGuideNode endNodeWithController:[ThirdViewController class]]
];
}

为了实现这样的效果,需要将调度器设计成单例,并且通过nodes数组这一属性接收步骤结点,上面提到,不涉及到页面切换的步骤完成无法被捕获,因此需要用户手动推进,因此调度器还需要一个next方法来进行手动推进,综上所述,调度器的设计如下:

@interface SGGuideDispatcher : NSObject

@property (nonatomic, strong) NSArray<SGGuideNode *> *nodes;

+ (instancetype)sharedDispatcher;
- (void)next;
// 重置引导步骤,用于调试
- (void)reset; @end

拦截器设计

上文提到,我们通过拦截viewWillAppear:方法来触发步骤的判断与切换,可以通过为UIViewController添加分类实现,在拦截后发出通知,以供调度器接收,如下:

@implementation UIViewController (Tracking)

+ (void)load {
method_exchangeImplementations(class_getInstanceMethod([self class], @selector(viewWillAppear:)), class_getInstanceMethod([self class], @selector(track_viewWillAppear:)));
} - (void)track_viewWillAppear:(BOOL)animated {
[self track_viewWillAppear:animated];
[[NSNotificationCenter defaultCenter] postNotificationName:SGGuideTrigNotification object:@{@"viewController":self}];
} @end

调度器开始调度的时机

上文提到调度器开始工作的时机是接收到步骤结点后,因此通过重写结点数组的setter来注册对拦截器通知的监听即可。

- (void)setNodes:(NSArray<SGGuideNode *> *)nodes {
_nodes = nodes;
// 重置游标
self.cur = 0;
// 防止重复注册
[[NSNotificationCenter defaultCenter] removeObserver:self];
[[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(trig:) name:SGGuideTrigNotification object:nil];
}

这样的设计十分明了,但是不利于对引导结束后再次启动App不开启调度的编程,故改良如下,通过Preference记录引导步骤游标cur的值,对于结束的引导cur为-1,如果cur是-1,则不接收步骤结点,防止浪费内存。

- (void)setNodes:(NSArray<SGGuideNode *> *)nodes {
if ([[NSUserDefaults standardUserDefaults] integerForKey:kSGGuideDispatcherCur] == -1) {
return;
}
_nodes = nodes;
if (self.cur < nodes.count) {
[[NSNotificationCenter defaultCenter] removeObserver:self];
[[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(trig:) name:SGGuideTrigNotification object:nil];
}
}

调度器触发的时机

通过上文我们知道,拦截器的通知触发了调度器的trig:方法,trig:方法用于处理调度器的触发逻辑,除此之外,还有手动触发调度器的方式,也通过发送通知实现。

- (void)next {
if (!self.currentViewController) return;
[[NSNotificationCenter defaultCenter] postNotificationName:SGGuideTrigNotification object:@{@"viewController":self.currentViewController}];
}

这里的currentViewController为当前展示的视图控制器,这个值在每次调度器触发时根据通知中的视图控制器来赋值,由于next前还没有进行页面切换,因此当前的视图控制器不变,依然是currentViewController。

调度器的触发逻辑

调度器每次触发时,首先根据游标拿出当前步骤结点,并判断当前显示的视图控制器是否和步骤结点要求的匹配,如果匹配,则添加遮盖,并将游标后移。

上文提到最后一个步骤结点是endNode,用于判断调度的结束,endNode与其他步骤结点的区别是允许交互的视图的keyPath为空,一旦发现keyPath为空,则认为调度结束,清空nodes释放内存并且移除通知,并记录游标的值为-1,以防止下次打开App时重复启动调度。

- (void)trig:(NSNotification *)nof {
if (self.cur >= self.nodes.count) return;
SGGuideMaskView *maskView = [SGGuideMaskView sharedMask];
UIViewController *topVc = nof.object[@"viewController"];
SGGuideNode *node = self.nodes[self.cur];
if ([topVc isKindOfClass:node.controllerClass]) {
self.currentViewController = topVc;
[maskView hide];
self.cur++;
if (node.permitViewPath == nil) {
self.nodes = nil;
[[NSNotificationCenter defaultCenter] removeObserver:self];
[[NSUserDefaults standardUserDefaults] setInteger:-1 forKey:kSGGuideDispatcherCur];
[[NSUserDefaults standardUserDefaults] synchronize];
return;
}
maskView.node = node;
[maskView showInViewController:topVc];
}
}

总结

实现用户引导有三个关键的类,引导结点SGGuideNode、遮盖层SGGuideMaskView和调度器SGGuideDispatcher,将引导结点的数组传递给调度器即可开始调度,调度的触发分为手动和自动两种方式,拦截器(UIViewController的分类)对页面切换进行拦截并触发调度,不涉及到页面切换的调度需要编程者通过调度器的next方法实现。每次触发调度时先判断是否与引导结点相符,相符则添加遮盖层并向后推进。

通过这样的设计,实现了几乎无侵入的用户引导,它不会破坏工程的结构,能提供良好的用户引导效果。

基于AOP的iOS用户操作引导框架设计的更多相关文章

  1. 我使用Spring AOP实现了用户操作日志功能

    我使用Spring AOP实现了用户操作日志功能 今天答辩完了,复盘了一下系统,发现还是有一些东西值得拿出来和大家分享一下. 需求分析 系统需要对用户的操作进行记录,方便未来溯源 首先想到的就是在每个 ...

  2. 基于facebook-wda的iOS自动化操作实践记录

    [本文出自天外归云的博客园] 原理 对于iOS自动化操作,主要靠WebDriverAgent来完成.在Mac电脑上连接真机iPhone,运行WebDriverAgentRunner会在Mac端启动WD ...

  3. 【iOS】小项目框架设计(ReactiveCocoa+MVVM+AFNetworking+FMDB)

    上一个项目使用到了ReactiveCocoa+MVVM+AFNetworking+FMDB框架设计,从最初的尝试,到后来不断思考和学习,现在对这样一个整体设计还是有了一定了理解与心得.在此与大家分享下 ...

  4. 开箱即用~基于.NET Core的统一应用逻辑分层框架设计

    目前公司系统多个应用分层结构各不相同,给运维和未来的开发带来了巨大的成本,分层架构看似很简单,但保证整个研发中心都使用统一的分层架构就不容易了. 那么如何保证整个研发中心都使用统一的分层架构,以达到提 ...

  5. 【Qt编程】基于Qt的词典开发系列<一>--词典框架设计及成品展示

    去年暑假的时候,作为学习Qt的实战,我写了一个名为<我爱查词典>的词典软件.后来由于导师项目及上课等原因,时间不足,所以该软件的部分功能欠缺,性能有待改善.这学期重新拿出来看时,又有很多东 ...

  6. IOS开发中如何判断程序第一次启动(根据判断结果决定是否显示新手操作引导)

    IOS开发中如何判断程序第一次启动 在软件下载安装完成后,第一次启动往往需要显示一个新手操作引导,来告诉用户怎么操作这个app,这就需要在程序一开始运行就判断程序是否第一次启动,如果是,则显示新手操作 ...

  7. 基于NopCommerce的开发框架——缓存、网站设置、系统日志、用户操作日志

    最近忙于学车,抽时间将Nop的一些公用模块添加进来,反应的一些小问题也做了修复.另外有园友指出Nop内存消耗大,作为一个开源电商项目,性能方面不是该团队首要考虑的,开发容易,稳定,代码结构清晰简洁也是 ...

  8. IOS基于XMPP协议开发--XMPPFramewok框架(一):基础知识

    最近蘑菇街团队的TT的开源,使我对im产生了兴趣,然后在网上找到了XMPPFramework进行学习研究, 并写了以下系列教程供大家参考,有写的不对的地方,请大家多多包涵指正. 目录索引 IOS基于X ...

  9. 基于SqlSugar的开发框架循序渐进介绍(8)-- 在基类函数封装实现用户操作日志记录

    在我们对数据进行重要修改调整的时候,往往需要跟踪记录好用户操作日志.一般来说,如对重要表记录的插入.修改.删除都需要记录下来,由于用户操作日志会带来一定的额外消耗,因此我们通过配置的方式来决定记录那些 ...

随机推荐

  1. 【API调用】腾讯云短信

    在之前介绍的火车票查询工具中,利用邮件和短信将查询结果推送给用户.免费短信的条数只有5条,用完之后只能单独使用邮件提醒. 最近发现腾讯云的福利,简单的介绍一下用法. 腾讯云->产品->通信 ...

  2. CentOS安装node.js-8.11.1+替换淘宝NPM镜像

    注:以下所有操作均在CentOS 6.8 x86_64位系统下完成. #准备工作# 由于node.js-8.11.1在源码编译安装的时候需要gcc 4.9.4或clang++ 3.4.2以上版本的支持 ...

  3. HTC Vive 叠影器无法创建设备

    今天使用笔记本电脑打开SteamVR发生错误:SteamVR启动失败,"Shared IPC Compositor Connected Fail(306)",然后启动失败,在UI界 ...

  4. 自动化运维:使用flask+mysql+highcharts搭建监控平台

    1.前言 本来想做一个比较完善的监控平台,只需要做少许改动就可以直接拿来用,但是在做的过程中发现要实现这个目标所需的工作量太大,而当前的工作中对其需求又不是特别明显.所以就退而求其次,做了一个类似教程 ...

  5. 学习HTML的第二次课

    1.图片 <img / > 图片的格式: 1.1BMP 占用空间大,颜色丰富. 1.2JPEG 有损压缩,占用空间较小. 1.3GIF 多帧动图,支持透明色. 1.4PNG 无损压缩,位图 ...

  6. github学习(三)

    Git学习(二) 分支学习: 创建新分支dev:git branch dev 切换到dev分支:git checkout dev 可以简写为一句话:git checkout -b dev 可以用命令g ...

  7. 基于webpack的React项目搭建(二)

    前言 前面我们已经搭建了基础环境,现在将开发环境更完善一些. devtool 在开发的过程,我们会经常调试,so,为了方便我们在chrome中调试源代码,需要更改webpack.config.js,然 ...

  8. [HAOI 2007]反素数ant

    Description 对于任何正整数x,其约数的个数记作g(x).例如g(1)=1.g(6)=4. 如果某个正整数x满足:g(x)>g(i) 0<i<x,则称x为反质数.例如,整数 ...

  9. ●BZOJ 4518 [Sdoi2016]征途

    题链: http://www.lydsy.com/JudgeOnline/problem.php?id=4518 题解: 斜率优化DP 首先看看最后答案的形式: 设a[i]为第i天走的距离,那么 $A ...

  10. VK Cup 2017 - Квалификация 2

    因为资格赛1已经通过了,资格赛2随便打打玩.这次题目比上次还简单,FallDream看了两眼觉得太水就不做了,我一个人闲着无聊只好默默做了 A. Новый пароль 题目大意:给出N和K,要求构 ...