优雅的构建联动菜单
这里所说的联动菜单,就是一个列表菜单,每一项的内容都取决于上一项的状态,效果如图:
![](https://img.haomeiwen.com/i1598087/7a7798787bbce009.gif)
这里发现虽然三个选择菜单按钮看起来互相独立,但是却又互相关联:
1,每一个菜单按钮都影响下一个按钮的可选状态,只有这个按钮已经选择了有效选项,下一个按钮才可以点击。
2,每一个菜单按钮的选择内容,取决于上一个按钮的选项。
3,每一个菜单按钮点击重新选择一个选项后,在它以下的所有菜单按钮都要重置状态。
最简单的办法就是我们在控制器的view中添加三个控件,然后通过监听每一个控件的点击后弹出菜单的选择结果,来重置每一项的状态刷新,这样控制器中会存在大量的逻辑代码,来做这个事情。
如果能够这样完成我们的需求,代码是不是就清爽很多:
#pragma mark - 添加菜单项
- (void)addSelectViews {
// 计算每一个菜单项的frame
CGFloat M = 10;
CGFloat W = self.view.width - 30;
CGFloat H = 44;
CGFloat X = 15;
CGFloat Y = 0;
CGFloat TopMargin = 100;
JKRBaseSelectedView *preView = nil;
// 通过for循环添加菜单项
for (NSUInteger i = 0; i < 3; i++) {
Y = (M + H) * i + TopMargin;
JKRBaseSelectedView *selectedView = [[JKRBaseSelectedView alloc] initWithFrame:CGRectMake(X, Y, W, H)];
selectedView.delegate = self; // 设置菜单项的代理获取菜单选择回调
selectedView.selectedType = i;
if (preView) selectedView.pre = preView;
[self.view addSubview:selectedView];
preView = selectedView;
}
}
#pragma mark - 监听菜单点击
- (void)baseSelectedView:(JKRBaseSelectedView *)selectedView didSelected:(NSUInteger)index {
switch (selectedView.selectedType) {
case JKRSelectedTypeGame: // 第一个菜单项改变
self.gameIndex = index;
[self refreshGameResult];
break;
case JKRSelectedTypeArea: // 第二个菜单项改变
self.areaIndex = index;
[self refreshAreaResult];
break;
case JKRSelectedTypeGrade: // 第三个菜单项改变
self.gradeIndex = index;
[self refreshGradeResult];
break;
default:
break;
}
}
至于菜单项点击后选项列表弹出和收起,选择后菜单的UI刷新,全部由菜单项自己去处理,控制器只负责创建、添加和结果获取。
这样做,首先要考虑的就是怎么样才能让上图中的菜单项自己处理它们之间的依赖关系。其实这种依赖关系是单向的,即下一个会被上一个影响,但是上一个不会被下一个影响,而且这种影响是一级一级连锁传递的,是不是很像一个单链表呢。而每一个菜单项都需要弹出一个菜单,同时我们还需要一个统一的数据提供者,给菜单项提供数据。所以我们需要创建三个类:
JKRBaseSelectedView:菜单项
JKRSelectMenu:点击菜单项弹出的菜单列表
JKRSelectedStore:菜单数据提供者
菜单项:隔离自身业务逻辑代码,使控制器只关系它的创建添加和结果处理。需要处理多个菜单项之间的依赖关系,弹出菜单列表。
菜单列表:处理自己的弹出和隐藏实现,使菜单项控件只负责弹出和隐藏它。
数据提供址:为整个菜单控件提供统一的数据。
首先需要创建菜单项控件:
@protocol JKRBaseSelectedViewDelegate <NSObject>
@required
- (void)baseSelectedView:(JKRBaseSelectedView *)selectedView didSelected:(NSUInteger)index;
@end
@interface JKRBaseSelectedView : UIView
/// 菜单项选中的index
@property (nonatomic, assign) NSUInteger selectedIndex;
/// 菜单项的层级
@property (nonatomic, assign) JKRSelectedType selectedType;
/// 菜单项的顶级index
@property (nonatomic, assign) JKRGameType gameType;
/// 当前菜单项是否可以点击
@property (nonatomic, assign) BOOL enable;
/// 当前菜单项的上一级菜单项
@property (nonatomic, strong) JKRBaseSelectedView *pre;
/// 菜单选择回调代理
@property (nonatomic, weak) id<JKRBaseSelectedViewDelegate> delegate;
@end
在添加它的上一个节点(pre)的时候,通过监听它的上一级选择状态,并同步刷新自己的UI,这样实现监听的连锁传递:
- (void)setPre:(JKRBaseSelectedView *)pre {
_pre = pre;
[_pre jkr_addObserver:self forKeyPath:@"selectedIndex" change:^(id newValue) {
self.enable = self.pre.selectedIndex;
[self _refreshSelected];
}];
}
上面可以看到,当一个菜单项监听到上一级菜单项的selectedIndex改变的时候,首先判断自己的enable状态,然后刷新自己UI。菜单项的UI刷新和同步核心功能现在已经基本解决了。下面通过触摸事件来实现菜单项的点击事件处理:
- (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event {
if (!self.enable) return;
self.backgroundColor = [UIColor lightGrayColor];
}
- (void)touchesEnded:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event {
self.backgroundColor = [UIColor whiteColor];
if (!self.enable) return;
[self _onSelected];
@weakify(self);
[JKRSelectMenu showWithSourceView:self selectedBlock:^(NSUInteger index) {
@strongify(self);
if (index != 999) self.selectedIndex = index;
[self _endSelected];
}];
}
菜单列表并不需要菜单项提供列表数据,只需要知道自己是从哪个菜单项弹出即可,因为菜单数据是从统一的数据提供者JKRSelectedStore提供的,只需要知道弹出它的菜单项的层级和它的上一级的状态,就能够依次推导出菜单列表应该展示的数据:
+ (void)showWithSourceView:(JKRBaseSelectedView *)sourceView selectedBlock:(void (^)(NSUInteger))selectedBlock{
if (![[JKRSelectedStore sharedStore] selectItemsWithType:sourceView.selectedType gameType:sourceView.gameType]) return;
JKRSelectMenu *menu = [JKRSelectMenu selectMenuWithSourceView:sourceView];
menu.selectedBlock = selectedBlock;
[menu showWithSourceView:sourceView];
}
+ (instancetype)selectMenuWithSourceView:(JKRBaseSelectedView *)sourceView {
static JKRSelectMenu *instance;
static dispatch_once_t onceToken;
dispatch_once(&onceToken, ^{
instance = [[JKRSelectMenu alloc] init];
instance.backgroundColor = [UIColor whiteColor];
instance.layer.masksToBounds = YES;
});
[instance _configWithSourceView:sourceView];
return instance;
}
- (void)_configWithSourceView:(JKRBaseSelectedView *)sourceView {
self.items = [[JKRSelectedStore sharedStore] selectItemsWithType:sourceView.selectedType gameType:sourceView.gameType];
[self _setFrameWithSourceView:sourceView];
[self _setChildView];
}
通过数据配置好列表的尺寸后,最后将菜单列表弹出:
- (void)showWithSourceView:(JKRBaseSelectedView *)sourceView {
CGFloat height = self.height;
self.height = 0;
self.isShowing = YES;
[sourceView.jkr_viewController.view addSubview:self];
[UIView animateWithDuration:0.2 animations:^{
self.height = height;
} completion:^(BOOL finished) {
dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(0.2 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{
self.isShowing = NO;
});
}];
}
这里可以看到,菜单列表的弹出并没有新建UIWindow,也没有创建蒙版,只是计算好菜单列表应该在当前控制器的view中应该显示的frame。那么怎么实现点击菜单列表之外的区域隐藏菜单列表并且不响应其他区域的点击呢。因为菜单列表是当前控制器中最后添加的子视图,所以无论点击控制器的什么地方,响应链遍历原则都会首先遍历菜单列表,所以只需要重写响应链遍历方法:
- (BOOL)pointInside:(CGPoint)point withEvent:(UIEvent *)event {
return YES;
}
让菜单列表成为响应者,并重写touchesBegan方法阻断响应链的传递:
- (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event {
if (self.isShowing) return;
if(self.selectedBlock) self.selectedBlock(999);
[self hide];
}
通过这个思路,就能够实现将所有控件的业务封装到自身之中,让控件自己处理完自己应该完成的事情,上层控件只负责创建、展示和接收消息。
源码链接