iOS支持横滑的多tab布局

把18年做的事情整理一下,这次是个人中心布局的架构实现

简介

UI大写开头的,UIScrollView这种是指
小写开头的,scrollView这种是指实现

本次要做的是拥有公共头部,支持两个tab横滑的页面,其实就是抖音的个人中心

支持手势横滑的tab是用UIScrollView完成的,UIScrollView是在固定宽高的窗口内可以上下左右滑动的视图,用来承载屏幕上显示那些在有限区域内放不下的内容

正常的UIScrollView内每个pagingView都是独立的个体,上滑滚动时页面内所有元素跟随滚动

比如这样:

personCenterV

可以看到,UIScrollView内放置了两个和手机屏幕等宽的页面,可以横滑。每个页面都是单独的内容流,可以上滑显示更多内容。注意,顶部的搜索和导航tab是放在UIScrollView外面的,可以看到并没有跟随页面上滑。

本次解决的问题是,每个页面是相对独立的,横滑的时候是页面为单位的横滑,如何保持同一块视图不跟随横滑,并且又能跟随页面上下滚动

实现效果(demo做了简化,没有演示吸顶和点击tab):

personCenterV

具体思路

这里的实现方式是,把共享视图单独做一个UIView,盖在UIScrollView上面,这样UIScrollView横滑时,共享视图保持不动(共享视图命名为headerView)

随之而来是三个需要解决的问题:

  • headerView不会随着页面上下滚动
  • 手指在headerView上滑动的时候,scrollView不会接收手势事件进行滑动
  • 手指在headerView的subview(子集UIView)上滑动的时候,scrollView不会接收手势事件进行滑动

下面我们一个一个的解决这些问题:

让headerView随着页面上下滚动

先讲一下层级关系,根据包含层级,从底层到上层:UIViewController > UIScrollView > pagingView(单独创建的页面UIView) > UICollectionView(流布局)

headerView和scrollView的父级都是viewController,但是headerView盖在scrollView上面。scrollView里面包含了两个pagingView,横滑切换就是两个pagingView位置的切换

实现方式是在pagingView内实现UIScrollView的这个delegate:

- (void)scrollViewDidScroll:(UIScrollView *)scrollView;

接收到pagingView在上下滑动的通知后,修改headerView的位置

代码一共涉及四个文件:

GXFMineViewController : UIViewController
GXFScrollView : UIScrollView
GXFMinePagingView : UIView
GXFMineHeaderAreaView : UIView

- (void)scrollViewDidScrollWithY {
if (self.isPagingScrolling) {
return;
}

GXFMinePagingView *currentCell = [_pagingView cellAtIndex:self.pagingCurrentIndex];
CGFloat offsetY = currentCell.collectionView.contentOffset.y - 64 - 20;
// 这里的210是headerView的高度,这里是demo简单写死了
CGFloat headerOffsetY = 210;
if (offsetY <= headerOffsetY) {
headerOffsetY = offsetY;
}

CGRect frame = self.headerAreaView.frame;
frame.origin.y = -headerOffsetY;
self.headerAreaView.frame = frame;
}

这样就可以实现headerView随着页面上下滚动了,但要注意scrollView横滑的时候要及时修正headerView的位置,因为两个pagingView的滚动值contentOffset.y是不同的

因此,也需要在viewController内实现UIScrollView的这个delegate,不过上次处理的是纵向的滑动,这次处理的是横向的滑动:

-(void)scrollViewWillBeginDragging:(UIScrollView *)scrollView{
self.pagingCurrentIndex = _pagingView.currentCellIndex;
}

- (void)scrollViewDidScroll:(UIScrollView *)scrollView {
self.isPagingScrolling = YES;

GXFMinePagingView *currentCell = [_pagingView cellAtIndex:self.pagingCurrentIndex];
if(currentCell && _pagingView.currentCellIndex == self.pagingCurrentIndex){
GXFMinePagingView * cell = [_pagingView cellAtIndex:(self.pagingCurrentIndex+1)%2];
CGFloat offsetY = currentCell.collectionView.contentOffset.y;
CGFloat contentH = cell.collectionView.contentSize.height;

// 从页面A滑动到页面B时,如果B的真实高度比较小,为了避免显示空内容,页面B纵向滚动到吸顶位置
offsetY = (offsetY >= contentH) ? 210 : offsetY;

if(cell){
[cell.collectionView setContentOffset:CGPointMake(0, offsetY) animated:NO];
}
}
}

- (void)scrollViewDidEndDecelerating:(UIScrollView *)scrollView {
self.pagingCurrentIndex = _pagingView.currentCellIndex;
self.isPagingScrolling = NO;
}

接收headerView的滑动手势事件

一次触摸事件是由一组UITouch对象状态变化引起的一组Touch message的转发和派送,然后UIResponser类用来接收和处理事件,常见的UIResponser有UIView及子类,UIViController,AppDelegate,UIApplication等等

用户点击之后会从第一个UIWindow对象开始,先判断UIWindow是否合格,其次判断点击位置在不在这个Window内,如果不在,返回nil,就换下一个UIWindow;如果在的话,并且UIWindow没有subView就返回自己,整个过程结束。如果UIWindow有subViews,就从后往前遍历整个subViews,做和UIWindow类似的事情,直到找到一个View。如果没有找到就不做传递

显示控件有两个方法来做上面这件事,就是常说的hitTest:

 // 先判断点是否在View内部,然后遍历subViews
- (nullable UIView *)hitTest:(CGPoint)point withEvent:(nullable UIEvent *)event;
//判断点是否在这个View内部
- (BOOL)pointInside:(CGPoint)point withEvent:(nullable UIEvent *)event;

我们初始化headerView的时候,传递pagingView的collectionView进去:

- (GXFMineHeaderAreaView *)headerAreaView {
if (_headerAreaView == nil) {
_headerAreaView = [[GXFMineHeaderAreaView alloc] initWithFrame:CGRectMake(20, 64 + 20, self.view.frame.size.width - 40, 210)];
_headerAreaView.backgroundColor = [UIColor lightGrayColor];
_headerAreaView.delegate = self;

__weak typeof(self)weakSelf = self;
_headerAreaView.getRespondView = ^UIView *{
if (weakSelf == nil) {
return nil ;
}
GXFMinePagingView * cell = [weakSelf.pagingView cellAtIndex:weakSelf.pagingView.currentCellIndex];
if (cell) {
return cell.collectionView;
}
return weakSelf.headerAreaView;
};
}
return _headerAreaView;
}

然后重写headerView的hitTest方法,将headerView的手势事件转移给pagingView的collectionView,在headerView上滑动就相当于在pagingView的collectionView上滑动

- (UIView*)hitTest:(CGPoint)point withEvent:(UIEvent *)event {
UIView *respondView = nil;

if ((_headerView && _headerView.superview && CGRectContainsPoint(_headerView.frame, point))) {
NSArray *subviews = self.headerView.subviews;
for (UIView *subview in subviews) {
// 这里判断headerView上的UIButton类型子视图依旧响应手势
// 不然,按钮不识别手势就无法处理点击事件了
// 也就是说按钮这块滑动,不会响应scrollView的滑动
if ([subview isKindOfClass:[UIButton class]]) {

CGPoint newPoint = [subview convertPoint:point fromView:self.headerView];

if (CGRectContainsPoint(subview.bounds, newPoint)) {
respondView = [super hitTest:point withEvent:event];
}
}
}
if(respondView == nil){
if(self.getRespondView){
respondView = self.getRespondView();
}
}
}
if(respondView == nil){
respondView = [super hitTest:point withEvent:event];
}

return respondView;
}

headerView上子视图的滑动手势事件

因为按钮一定要响应手势事件的,所以不能使用上面的解决方案。于是实现了headerView的手势事件,根据手势事件对象的点击位置,对前后移动位置计算差值,模拟上下滑动效果

headerView.m

- (instancetype)initWithFrame:(CGRect)frame
{
self = [super initWithFrame:frame];
if (self) {
self.currentPointForPan = CGPointZero;
[self addSubview: self.headerView];

// 添加手势事件
_verticalPanGesture = [[UIPanGestureRecognizer alloc]initWithTarget:self action:@selector(onPanGesture:)];
_verticalPanGesture.delegate = self;
[self addGestureRecognizer:_verticalPanGesture];
}
return self;
}

// 手势delegate,满足一定条件才允许手势状态更改
-(BOOL)gestureRecognizerShouldBegin:(UIGestureRecognizer *)gestureRecognizer {
// 如果手势识别为拖动
if([gestureRecognizer isKindOfClass:[UIPanGestureRecognizer class]]) {
UIPanGestureRecognizer *panGesture = (UIPanGestureRecognizer*)gestureRecognizer;
CGPoint velocityPoint = [panGesture velocityInView:self.superview];
// 如果手指运动是上下滑动
if(fabs(velocityPoint.y) > fabs(velocityPoint.x)) {
return YES;
}
}
return NO;
}
// 实现手势事件
-(void)onPanGesture:(UIPanGestureRecognizer*)gesture {
CGPoint locationPoint = [gesture locationInView:self.superview];
CGPoint velocityPoint = [gesture velocityInView:self.superview];

if(!CGPointEqualToPoint(self.currentPointForPan, CGPointZero) &&
(gesture.state == UIGestureRecognizerStateEnded || gesture.state == UIGestureRecognizerStateCancelled)) {

CGFloat offsetY = locationPoint.y - self.currentPointForPan.y;
if(self.delegate &&
[self.delegate conformsToProtocol:@protocol(GXFMineHeaderViewAreaDelegate)] &&
[self.delegate respondsToSelector:@selector(onDraftedHeaderAreaViewWithOffset:)]){

[self.delegate onDraftedHeaderAreaViewWithOffset:CGPointMake(0, offsetY)];
}
self.currentPointForPan = CGPointZero;
}

if(fabs(velocityPoint.y) > fabs(velocityPoint.x)){
switch (gesture.state) {
case UIGestureRecognizerStateBegan:
{
self.currentPointForPan = locationPoint;
}
break;
case UIGestureRecognizerStateChanged:
{
CGFloat offsetY = locationPoint.y - self.currentPointForPan.y;
if(self.delegate &&
[self.delegate conformsToProtocol:@protocol(GXFMineHeaderViewAreaDelegate)] &&
[self.delegate respondsToSelector:@selector(onDraftingHeaderAreaViewWithOffset:)]){

[self.delegate onDraftingHeaderAreaViewWithOffset:CGPointMake(0, offsetY)];
}
self.currentPointForPan = locationPoint;
}
break;
case UIGestureRecognizerStateCancelled:
case UIGestureRecognizerStateEnded:
{
}
default:
break;
}
}
}

GXFMineViewController.m

-(void)onDraftingHeaderAreaViewWithOffset:(CGPoint)offset{
GXFMinePagingView * cell = [self.pagingView cellAtIndex:self.pagingView.currentCellIndex];
if(cell){
CGPoint currentOffset = cell.collectionView.contentOffset;
currentOffset.x += offset.x;
currentOffset.y += (-offset.y);
if(currentOffset.y > 0){
[cell.collectionView setContentOffset:currentOffset animated:NO];
}
}
}

-(void)onDraftedHeaderAreaViewWithOffset:(CGPoint)offset{
GXFMinePagingView * cell = [self.pagingView cellAtIndex:self.pagingView.currentCellIndex];
if(cell){
CGPoint currentOffset = cell.collectionView.contentOffset;
currentOffset.x += offset.x;
currentOffset.y += (-offset.y);
CGFloat maxOffset = fabs(cell.collectionView.contentSize.height - cell.collectionView.bounds.size.height);
if(currentOffset.y < 0){
[cell.collectionView setContentOffset:CGPointMake(0, 0) animated:YES];
}
else{
if(ceil(currentOffset.y) <= maxOffset && cell.collectionView.contentSize.height > cell.collectionView.bounds.size.height){
[cell.collectionView setContentOffset:currentOffset animated:NO];
}
else{
CGFloat offsetY = 0.f;
if(cell.collectionView.contentSize.height > cell.collectionView.bounds.size.height){
offsetY = maxOffset;
}
[cell.collectionView setContentOffset:CGPointMake(0, offsetY) animated:YES];
}
}
}
}

总结

上面就是基本的实现步骤,后续会考虑换一种方式实现,先到这里