UI层级的精简和控件的封装

2,219 阅读5分钟

当用户手指点击屏幕后,响应事件会按照响应者链逐级的找到应该响应该事件的控件。我们也可以自己通过代码来控制UI控件对于响应者链的判断逻辑,来改变一个UI控件本来默认的响应逻辑。这里不去解析响应链的遍历顺序,只举例一个实际应用的场景。

首先解析关于响应链的一个方法:

- (BOOL)pointInside:(CGPoint)point withEvent:(UIEvent *)event {
    // ...
}

每当点击事件发生后,相关的UI控件会按照响应链遍历顺序依次递归调用这个方法,返回的布尔值代表该点击事件是否在当前UI控件内部,如果返回YES,会继续向该UI控件内部的子控件去依次调用,直到找到最后的应该响应该点击事件的UI控件。

所以,重写这个方法可以修改响应者链判定的结果,如果固定返回NO,那么该UI控件就不会拦截响应事件,响应事件会继续向后传递。

需求

在一个界面中

  • 需要添加一个浮动的按钮。
  • 在点击按钮的时候,底部弹出一个菜单栏。
  • 弹出菜单栏时,按钮滑出屏幕隐藏。
  • 弹出菜单栏时,界面被一个半透明蒙版遮盖。
  • 弹出菜单栏时,点击半透明蒙版关闭菜单栏。

正常的实现方式

正常的实现方式一般如下:

  • 界面添加一个子控件 按钮。
  • 界面添加一个子控件 蒙版。
  • 界面添加一个子控件 菜单栏。

通过按钮、蒙版、菜单栏的各种交互实现三个控件的移动、显示逻辑才完成需求。

利用重写响应者链方法进行封装

只创建一个子控件 AlertView,AlertView创建时和界面的试图大小一致,以达到覆盖整个界面实现蒙版的功能。

将按钮、菜单栏全部添加到AlertView中,使其称为AlertView的子控件。

菜单栏由一个UITableView对象实现,默认的frame中的y为屏幕的高度,这样达到隐藏的效果:

- (UITableView *)tableView {
    if (!_tableView) {
        CGFloat x = 0;
        CGFloat w = kScreenWidth;
        CGFloat h = kCellHeight * 3 + (kIsIPhoneX ? 35 : 0);
        CGFloat y = kScreenHeight;
        _tableView = [[UITableView alloc] initWithFrame:CGRectMake(x, y, w, h)];
        _tableView.backgroundColor = [UIColor whiteColor];
        _tableView.delegate = self;
        _tableView.dataSource = self;
        _tableView.scrollEnabled = NO;
        [_tableView registerClass:[UITableViewCell class] forCellReuseIdentifier:@"alertcell"];
    }
    return _tableView;
}

浮动按钮默认直接显示在屏幕右侧:

- (UIButton *)alertButton {
    if (!_alertButton) {
        _alertButton = [UIButton buttonWithType:UIButtonTypeCustom];
        [_alertButton setBackgroundImage:[UIImage imageNamed:@"tool"] forState:UIControlStateNormal];
        _alertButton.frame = CGRectMake(kScreenWidth - 22, kScreenHeight - self.tableView.height - 10, 22, 43);
        [_alertButton addTarget:self action:@selector(alertButtonClick:) forControlEvents:UIControlEventTouchUpInside];
    }
    return _alertButton;
}

点击按钮的时候,将菜单栏向上移动,按钮右移隐藏,并同时改变AlertView的背景色,达到显示蒙版的效果:

- (void)alertButtonClick:(UIButton *)sender {
    [UIView animateWithDuration:0.3 animations:^{
        sender.x = kScreenWidth;
        self.tableView.y = kScreenHeight - self.tableView.height;
        self.backgroundColor = [UIColor colorWithWhite:0.0 alpha:0.3];
    } completion:^(BOOL finished) {
        
    }];
}

重写AlertView的touchesBegan:WithEvnet: 方法,点击AlertView时,下移隐藏菜单栏,左移显示按钮,并将AlertView的背景色设置为透明度,达到隐藏蒙版的效果:

- (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event {
    [self dismissAlert];
}

- (void)dismissAlert {
    [UIView animateWithDuration:0.3 animations:^{
        self.alertButton.x = kScreenWidth - self.alertButton.width;
        self.tableView.y = kScreenHeight;
        self.backgroundColor = [UIColor clearColor];
    } completion:^(BOOL finished) {
        
    }];
}

上面的代码有一个非常大的问题,就是即便菜单栏隐藏的时候,AlertView会拦截屏幕的点击事件。下面就是最重要的地方,重写AlertView的pointInside: withEvent: 方法:

- (BOOL)pointInside:(CGPoint)point withEvent:(UIEvent *)event {
    // 当菜单栏隐藏的时候,只有点击在按钮区域内,才会成为响应者。
    if (self.tableView.y == kScreenHeight) {
        return CGRectContainsPoint(self.alertButton.frame, point);
    } else { // 当菜单栏显示时,采用默认的响应逻辑。
        return [super pointInside:point withEvent:event];
    }
}

当菜单栏显示时,AlertView的使用默认的响应逻辑,拦截界面的点击事件。

当菜单栏隐藏时,只有点击区域是在AlertView的浮动按钮范围内时,才会拦截点击事件,否则AlertView会返回NO,响应者链遍历过程中会判断AlertView屏幕点击事件不在AlertView内,继续遍历到当前界面,并让当前界面成为响应者。

这样就只需要为当前界面添加一个子控件即可完成浮动按钮、蒙版、菜单栏的全部需求。

其它类似的应用场景

利用上面的方式,还可以应用在其它类似的应用场景。

类似微信首页添加好友按钮点击后出现的下拉菜单

可以创建一个仅有菜单显示面积大小的控件,重写AlertView的pointInside: withEvent: 方法,直接返回YES,这样这个菜单即使frame只有显示面积那么大,还是能够拦截全屏幕的点击事件。然后在重写它的touchBegin方法去因此菜单栏即可。这样就可以省去创建一个屏幕大小的背景色为透明的蒙版控件,就可以达到拦截点击菜单可见范围以外区域隐藏菜单栏的需求。

部分可穿透点击事件到后面的蒙版

比如一些给当前界面加修饰边框的蒙版,边框要拦截点击事件,但是中间透明部分又可以点击界面。这里就可以通过重写pointInside: withEvent: 判断点击区域是边框范围就返回YES,否则就返回NO。

异形按钮的实现

比如类似于拼图一样的非正方体控件密集排列,还需要让它们的点击事件不能相互冲突,下面是一个具体实现的例子:iOS响应者链的具体应用-异形按钮