基于AOP的iOS用户操作引导框架设计
来源:互联网 发布:abb工业机器人编程 编辑:程序博客网 时间:2024/06/14 00:35
背景
有一种现象,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用户操作引导框架设计
- 设计引导用户操作
- 基于aop的用户操作日志
- 基于PostSharp的AOP框架设计
- 基于注解的aop实现用户操作日志管理
- 基于aspectj的aop操作
- iOS用户引导页的简单实现
- 浅谈用户引导设计
- ios用户引导页
- spring框架基于注解aop的通知
- day3_Spring_02_基于aspectj的注解aop操作
- aop记录用户操作
- 基于UIScrollView和UIPageControl控件做的用户引导界面
- 基于AOP操作日志
- 基于AOP操作日志
- 网站用户框架的设计
- 如何设计新手用户引导
- 如何设计新手用户引导
- android关于关闭任意的activity
- iOS 常见bug
- Linux_Nginx安装教程
- 【玩转数据系列三】利用图算法实现金融行业风控
- Android单线程模型中Message、Handler、Message Queue、Looper之间的关系
- 基于AOP的iOS用户操作引导框架设计
- Mac常见问题
- checkbox
- top free命令真的能得到实际内存使用状况吗?
- EasyUi常用组件(二)Tree
- 详细解析 RxAndroid 的使用方式
- 认识Router
- 设计模式----模板方法模式UML和实现代码
- ScrollView嵌套ListView显示不全解决方案