iOS 玩转微信——下拉小程序

⭐️ 概述

  • 本文笔者将手把手带领大家像素级还原微信下拉小程序的实现过程。尽量通过简单易懂的言语,以及配合关键代码,详细讲述该功能实现过程中所运用到的技术和实现细节,以及遇到问题后如何解决的心得体会。希望正有此功能需要的小伙伴们,能够通过阅读本文后,能快速将此功能真正运用到实际项目开发中去。

  • 当然,笔者的实现方案不一定是微信官方的实现,毕竟一千个观众眼中有一千个潘金莲,但是,不管黑猫白猫,能捉老鼠的就是好猫,若能够实现此功能,相信也是一个不错的方案。希望该篇文章能为大家提供一点思路,少走一些弯路,填补一些细坑。文章仅供大家参考,若有不妥之处,还望不吝赐教,欢迎批评指正。

  • 源码地址:WeChat

🌈 预览

ios_mainframe_pulldown_applet_page.gif

🔎 分析

📦 模块

  • 三个球指示模块: 微信主页下拉时,用于指示用户的下拉处于哪个阶段。(MHBouncyBallsView.h/m)

  • 小程序模块: 展示我的小程序最近使用的小程序,以及搜索小程序的功能。(MHPulldownAppletViewController.h/m)

  • 云层模块: 背景云层展示。(WHWeatherView.h/m)

  • 小程序容器模块: 承载小程序模块云层模块、蒙版,以及处理上拉滚动逻辑。(MHPulldownAppletWrapperViewController.h/m)

  • 微信首页模块: 承载小程序容器模块,展示首页内容,以及处理下拉滚动逻辑。(MHMainFrameViewController.h/m)

🚩 阶段

本功能主要涵盖两大阶段:下拉显示小程序阶段上拉隐藏小程序阶段;当然,用户手指上拉或下拉阶段都涉及到以下三种状态:

  • MHRefreshStateIdle: 普通闲置状态(默认)
  • MHRefreshStatePulling: 松开就可以进行刷新的状态
  • MHRefreshStateRefreshing: 正在刷新中的状态

这里简要讲讲微信上拉或下拉进入MHRefreshStatePulling状态的条件:

  • 下拉阶段: 下拉超过临界点,且保证必须是下拉状态,即: 当前下拉偏移量 > 上一次的下拉偏移量。
  • 上拉阶段: 保证必须是上拉状态,即: 当前上拉偏移量 > 上一次的上拉偏移量,或者 偏移量为零且下拉。

松手检测:

  • 下拉阶段: 可以利用scrollView.isDragging来检测即可。
  • 上拉阶段: scrollView.isDragging 这个属性不好使,后面会给出替代方案。

📌 方案

考虑到小程序容器模块小程序模块的UI页面复杂、业务逻辑繁琐,以及涉及到模块下钻等场景,这里采用父子控制器的方案来实现,主要用到以下API:

  • 添加子控制器
[parentController.view addSubview:childController.view]; 

[parentController addChildViewController:childController];

[childController didMoveToParentViewController:parentController];
  • 移除子控制器
[childController willMoveToParentViewController:nil];

[childController.view removeFromSuperview];

[childController removeFromParentViewController];

整体的功能布局如下:

  • 小程序容器模块微信首页模块的子控制器。
  • 三个球指示模块微信首页模块的子控件。

  • 小程序模块小程序容器模块的子控制器。

  • 云层模块小程序容器模块的子控件。

整体的层级结构如下:<从上到下>

三个球模块 --> 小程序模块 --> 上拉UIScollView --> 云层模块 --> 黑色蒙版 --> 小程序容器模块.view --> 微信首页内容UITableView

🚀 实现

通过上面的层级分析和模块划分,我们可以针对下拉阶段上拉阶段,得出各个模块内部在这两个阶段分别作了怎样的处理,以及具体的实现过程。这里特别提醒❗️:分析过程看似简单的一逼,实现起来还是得细节拉满...

⬇️ 下拉阶段

下拉阶段: 无非就是监听微信首页内容UITableView的滚动,首先,根据UITableView下拉拖拽过程中产生的偏移量(contentOffset.y),从而影响各个模块的UI变化;然后,根据用户手指下拉拖拽的距离,判断当前下拉过程中处于哪个状态;最后,当用户结束拖拽(松手)后,是进入普通闲置状态还是正在刷新中的状态,从而呈现不同的UI效果。这里先贴出下拉过程中的关键代码,然后根据代码来分析各个模块的具体实现:

/// tableView 以滚动就会调用
/// 这里的逻辑 完全可以参照 MJRefreshHeader
- (void)scrollViewDidScroll:(UIScrollView *)scrollView {
    
    // 在刷新的refreshing状态 do nothing...
    if (self.state == MHRefreshStateRefreshing) {
        return;
    }else if(self.state == MHRefreshStatePulling && !scrollView.isDragging) {
        /// fixed bug: 这里设置最后一次的偏移量 以免回弹
        [scrollView setContentOffset:CGPointMake(0, self.lastOffsetY)];
    }
    
    
    // 当前的contentOffset
    CGFloat offsetY = scrollView.mh_offsetY;
    // 头部控件刚好出现的offsetY
    CGFloat happenOffsetY = -self.contentInset.top;
    
    // 如果是向上滚动到看不见头部控件,直接返回
    // >= -> >
    if (offsetY > happenOffsetY) return;
    
    // 普通 和 即将刷新 的临界点
    CGFloat normal2pullingOffsetY = - MHPulldownAppletCriticalPoint1 ;
    
    /// 计算偏移量 正数
    CGFloat delta = -(offsetY - happenOffsetY);
    
    // 如果正在拖拽
    if (scrollView.isDragging) {
        
        /// 更新 navBar 的 y
        [self.navBar mas_updateConstraints:^(MASConstraintMaker *make) {
            make.top.equalTo(self.view).with.offset(delta);
        }];
        
        /// 更新 ballsView 的 h
        [self.ballsView mas_updateConstraints:^(MASConstraintMaker *make) {
            CGFloat height = delta;
            make.height.mas_equalTo(MAX(6.0f, height));
        }];
        
        /// 传递offset
        self.viewModel.ballsViewModel.offsetInfo = @{@"offset": @(delta), @"state": @(self.state), @"animate": @NO};;
        
        /// 微信方案:不仅要下拉超过临界点 而且 保证是下拉状态:当前偏移量 > 上一次偏移量
        if (self.state == MHRefreshStateIdle && -delta < normal2pullingOffsetY && offsetY < self.lastOffsetY) {
            // 转为即将刷新状态
            self.state = MHRefreshStatePulling;
        } else if (self.state == MHRefreshStatePulling && (-delta >= normal2pullingOffsetY || offsetY >= self.lastOffsetY)) {
            // 转为普通状态
            self.state = MHRefreshStateIdle;
        }
        
        /// 传递状态
        self.viewModel.appletWrapperViewModel.offsetInfo = @{@"offset": @(delta), @"state": @(self.state)};
        
        /// 记录偏移量
        self.lastOffsetY = offsetY;
        
    } else if (self.state == MHRefreshStatePulling) {
        
        self.lastOffsetY = .0f;
        
        self.state = MHRefreshStateRefreshing;
    } else {
        /// 更新 navBar y
        [self.navBar mas_updateConstraints:^(MASConstraintMaker *make) {
            make.top.equalTo(self.view).with.offset(delta);
        }];
        
        /// 更新 ballsView 的 h
        [self.ballsView mas_updateConstraints:^(MASConstraintMaker *make) {
            CGFloat height = delta;
            make.height.mas_equalTo(MAX(6.0f, height));
        }];
        
        /// 传递offset
        self.viewModel.ballsViewModel.offsetInfo = @{@"offset": @(delta), @"state": @(self.state), @"animate": @NO};
        
        /// 传递状态
        self.viewModel.appletWrapperViewModel.offsetInfo = @{@"offset": @(delta), @"state": @(self.state)};
        
        /// 记录偏移量
        self.lastOffsetY = offsetY;
    }
}

#pragma mark - Setter & Getter
- (void)setState:(MHRefreshState)state {
    MHRefreshState oldState = self.state;
    if (state == oldState) return;
    _state = state;
    
    // 根据状态做事情
    if (state == MHRefreshStateIdle) {
        if (oldState != MHRefreshStateRefreshing) return;
        
        /// 动画过程中 禁止用户交互
        self.view.userInteractionEnabled = NO;
        
        /// 更新位置
        [self.navBar mas_updateConstraints:^(MASConstraintMaker *make) {
            make.top.equalTo(self.view).with.offset(0);
        }];
        
        /// 更新 ballsView 的 h
        [self.ballsView mas_updateConstraints:^(MASConstraintMaker *make) {
            CGFloat height = 0;
            make.height.mas_equalTo(MAX(6.0f, height));
        }];
        
        /// 传递offset
        self.viewModel.ballsViewModel.offsetInfo = @{@"offset": @(0), @"state": @(state), @"animate": @YES};
        
        // 先置位到最底下 后回到原始位置; 因为小程序 下钻到下一模块 tabBar会回到之前的位置
        self.tabBarController.tabBar.mh_y = MH_SCREEN_HEIGHT;
        self.tabBarController.tabBar.alpha = .0f;
        
        [UIView animateWithDuration:MHPulldownAppletRefreshingDuration animations:^{
            /// 导航栏相关 回到原来位置
//            self.tabBarController.tabBar.hidden = NO;
            self.tabBarController.tabBar.alpha = 1.0f;
            self.tabBarController.tabBar.mh_y = MH_SCREEN_HEIGHT - self.tabBarController.tabBar.mh_height;
            
            /// 设置tableView y
            self.tableView.mh_y = 0;
            
            [self.view layoutIfNeeded];
            self.navBar.backgroundView.backgroundColor = MH_MAIN_BACKGROUNDCOLOR;
        } completion:^(BOOL finished) {
            
            /// 完成后 传递数据给
            self.tableView.showsVerticalScrollIndicator = YES;
            /// 动画结束 允许用户交互
            self.view.userInteractionEnabled = YES;
        }];
    } else if (state == MHRefreshStateRefreshing) {
        dispatch_async(dispatch_get_main_queue(), ^{
            
            /// 隐藏滚动条
            self.tableView.showsVerticalScrollIndicator = NO;
            
            /// 传递offset 正向下拉
            self.viewModel.ballsViewModel.offsetInfo = @{@"offset": @(MH_SCREEN_HEIGHT - MH_APPLICATION_TOP_BAR_HEIGHT), @"state": @(self.state), @"animate": @NO};
            
            /// 传递状态
            self.viewModel.appletWrapperViewModel.offsetInfo = @{@"offset": @(MH_SCREEN_HEIGHT - MH_APPLICATION_TOP_BAR_HEIGHT), @"state": @(self.state)};
            
            /// 最终停留点的位置
            CGFloat top = MH_SCREEN_HEIGHT;
            /// 更新位置
            [self.navBar mas_updateConstraints:^(MASConstraintMaker *make) {
                make.top.equalTo(self.view).with.offset(top - MH_APPLICATION_TOP_BAR_HEIGHT);
            }];
            
            /// 动画过程中 禁止用户交互
            self.view.userInteractionEnabled = NO;
            
            /// 动画
            [UIView animateWithDuration:MHPulldownAppletRefreshingDuration animations:^{
                [self.view layoutIfNeeded];
                
                // 增加滚动区域top
                self.tableView.mh_insetT = top;
                
                // ⚠️ FBI Warning:
                // Xcode Version 11.4.1 设置animated: NO 也不好使 总之下面这两个方法都不好使
                // Xcode Version 10.2.1 设置animated: NO 却好使
                /// 妥协处理:这里统一用 animated: Yes 来处理 然后控制动画时间 与 scrollView 的 setContentOffset:animated: 相近即可
                // 设置滚动位置 animated:YES 然后
                [self.tableView setContentOffset:CGPointMake(0, -top) animated:YES];
                /// 按照这个方式 会没有动画 tableView 会直接掉下去
//                [self.tableView setContentOffset:CGPointMake(0, -top)];
                
                /// - [iphone – UIScrollview setContentOffset与非线性动画?](http://www.voidcn.com/article/p-glnejqrs-bsv.html)
                /// - [iphone – 更改setContentOffset的速度:animated:?](http://www.voidcn.com/article/p-bgupiewh-bsr.html)
                
                self.navBar.backgroundView.backgroundColor = [UIColor whiteColor];
                
                /// 这种方式没啥动画
//                self.tabBarController.tabBar.hidden = YES;
                /// 这种方式有动画
                self.tabBarController.tabBar.alpha = .0f;
                self.tabBarController.tabBar.mh_y = MH_SCREEN_HEIGHT;
                
            } completion:^(BOOL finished) {
                
                /// 小tips: 这里动画完成后 将tableView 的 y 设置到 MH_SCREEN_HEIGHT - finalTop ; 以及 将contentInset 和 contentOffset 回到原来的位置
                /// 目的:后期上拉的时候 只需要改变tableView 的 y就行了
                CGFloat finalTop = self.contentInset.top;
                self.tableView.mh_y = MH_SCREEN_HEIGHT - finalTop;
                // 增加滚动区域top
                self.tableView.mh_insetT = finalTop;
                // 设置滚动位置
                [self.tableView setContentOffset:CGPointMake(0, -finalTop) animated:NO];
                /// 动画结束 允许用户交互
                self.view.userInteractionEnabled = YES;
            }];
        });
    }
}

微信首页

下拉拖拽过程:即scrollView.isDragging == YES,该过程主要是:1、修改自定义导航栏的Y值。 2、计算当前下过过程中处于什么状态(Pullingor Idle)。3、传递偏移量状态三个球指示模块下拉程序容器模块

下拉松手过程:即scrollView.isDragging ==NO,如果下拉拖拽过程中的状态时Pulling,那么松手的瞬间会进入到Refreshing;反之,则回弹到原始下拉过程中,即默认状态(Idle)。

刷新状态逻辑:手指释放:下拉状态由 Pulling --> Refreshing,该过程主要都是动画过渡:1、导航栏的动画过渡到最底部以及修改背景色。2、UITableView内容页过渡到最底部。 3、UITabBar动画过渡到屏幕的最底部。4、传递偏移量状态三个球指示模块下拉程序容器模块

❗️❗️❗️细节处理如下👇:
Q1:由于下拉过程到达Pulling状态,立即松手,UITableView会回弹一点点,导致进入RefreshingTableView动画过渡不够丝滑。
A1:加个判断,逻辑如下

// 在刷新的refreshing状态 do nothing...
if (self.state == MHRefreshStateRefreshing) {
    return;
}else if(self.state == MHRefreshStatePulling && !scrollView.isDragging) {
    /// fixed bug: 这里设置最后一次的偏移量 以免回弹
    [scrollView setContentOffset:CGPointMake(0, self.lastOffsetY)];
}

Q2:下拉状态由 Pulling --> Refreshing,过渡动画阶段禁止用户交互,以免此状态下用户上拉或下拉,导致界面紊乱。(PS:目前微信App你上拉下拉,就会导致Refreshing状态下,UITabBar依然显示的Bug)
A2:动画开始前:self.view.userInteractionEnabled = NO; 动画完成后:self.view.userInteractionEnabled = YES;

Q3:下拉拖拽过程的Pulling状态判断,下拉超过临界点,且保证必须是下拉状态,即: 当前下拉偏移量 > 上一次的下拉偏移量。这是微信官方App的做法:若下拉超过临界点,然后你上拉一段距离,并且此时偏移量依然超过临界点,此时松手时下拉状态为Idle,而不是Puling。若设置为Puling,那么会进入Refreshing,进行过渡动画,内容页TableView回先向上回弹,然后再掉下去,即动画过渡不够丝滑。
A3:判断条件如下:

/// 微信方案:不仅要下拉超过临界点 而且 保证是下拉状态:当前偏移量 > 上一次偏移量
if (self.state == MHRefreshStateIdle && -delta < normal2pullingOffsetY && offsetY < self.lastOffsetY) {
    // 转为即将刷新状态
    self.state = MHRefreshStatePulling;
} else if (self.state == MHRefreshStatePulling && (-delta >= normal2pullingOffsetY || offsetY >= self.lastOffsetY)) {
    // 转为普通状态
    self.state = MHRefreshStateIdle;
}

Q4TabBar动画问题。首先,下拉 Refreshing,过渡动画中,若设置hidden属性,其实没有动画的,导致隐藏的比较生硬。其次,考虑小程序模块中点击某个小程序,会下钻二级页面,由于tabBar会被系统强制显示,导致返回到主页时,tabBar依然显示的Bug;

A4:用alpha和设置tabBar.y来代替hidden方案,这样就能形成,TabBar向下丝滑掉下的错觉。其次,根据微信主页当前的下拉状态是否为Refreshing,在viewWillAppear:viewWillDisappear:,控制其显示和隐藏。

/// 动画
[UIView animateWithDuration:MHPulldownAppletRefreshingDuration animations:^{
    /// 这种方式没啥动画
    /// self.tabBarController.tabBar.hidden = YES;
    /// 这种方式有动画
    self.tabBarController.tabBar.alpha = .0f;
    self.tabBarController.tabBar.mh_y = MH_SCREEN_HEIGHT;
} completion:^(BOOL finished) {
    /// code
}];


- (void)viewWillAppear:(BOOL)animated {
    [super viewWillAppear:animated];
    // 这里也根据条件设置隐藏
    self.tabBarController.tabBar.alpha = (self.state == MHRefreshStateRefreshing ? .0f : 1.0f) ;
}

- (void)viewWillDisappear:(BOOL)animated {
    [super viewWillDisappear:animated];
    // 这里也根据条件设置隐藏
    self.tabBarController.tabBar.alpha = (self.state == MHRefreshStateRefreshing ? .0f : 1.0f) ;
}

Q5:微信内容页(TableView)过渡动画问题。在下拉松手进入Refreshing状态的过渡动画中,tableView也得丝滑过渡到最底部,其实实现过程,无非是设置tableView.contentInset.top = MH_SCREEN_HEIGHTtableView.contentOffset.y = - MH_SCREEN_HEIGHT,但是,理想很丰满,现实很骨感,
我们可以将UIView的动画时间设置大一些,可以清楚的发现,内容页tableView是立即掉下去的,丝毫不见动画;当然,UIScrollView 也提供一个API动画滚动指定位置setContentOffset: animated:

这里拓展一下:setContentOffset:setContentOffset:animated:的异同点:

  • setContentOffset:animated:这种方法,无论animatedYES还是NO, 都会等待scrollView的滚动结束以后才会执行,也就是当isDraggingisDeceleratingYES的时候,会等待滚动完成才执行上面的方法。
  • setContentOffset:这种方法则不受scrollView是否正在滚动的限制。
  • 使用animated参数,可以获得正确的UIScrollViewDelegate的回调;而使用UIView动画则不能。
    • scrollViewDidScroll:
    • scrollViewDidEndScrollingAnimation:
  • 不使用animated参数,只可以回调scrollViewDidScroll:
  • 使用animated参数,可以获取到动画过程中contentOffset的值。
[scrollView setContentOffset:CGPointMake(0, 100) animated:YES];
NSLog(@"%f", scrollView.contentOffset.y);//输出:0.000000
dispatch_after(dispatch_time(DISPATCH_TIME_NOW, 0.1 * NSEC_PER_SEC), dispatch_get_main_queue(), ^{
    NSLog(@"%f", scrollView.contentOffset.y);//输出:25.500000,每次输出不保证一致
});
  • 不使用animated参数,使用UIView动画后,无论在什么时候查询contentOffset的值,得到的都是动画的最终值。
[UIView animateWithDuration:0.25 animations:^{
    [scrollView setContentOffset:CGPointMake(0, 100)];
}];
NSLog(@"%f", scrollView.contentOffset.y);//输出:100.000000

由于我们使用的是UIView动画,所以这里只需要在animations的代码块中设置[self.tableView setContentOffset:CGPointMake(0, -MH_SCREEN_HEIGHT) animated:NO];即可。但是又遇到一个神奇的Bug,在Xcode Version 10.2.1设置animated: NO可以实现tableView丝滑落下,然而在Xcode Version 11.4.1设置animated: NO却是直接掉下。笔者测试还发现,如果设置[self.tableView setContentOffset:CGPointMake(0, -400) animated:NO];却不受Xcode版本限制,这又是为何?? 有知道原因的小伙伴,请私信笔者哈。

A5:考虑到上述的原因后,笔者最后用了个妥协的方法,就是在animations的代码块中设置[self.tableView setContentOffset:CGPointMake(0, -MH_SCREEN_HEIGHT) animated:YES];即可,由于下拉过渡动画比较快,只需要设置UIView动画时间和setContentOffset:animated:相近即可。最终代码如下:

/// 动画
[UIView animateWithDuration:MHPulldownAppletRefreshingDuration animations:^{

    // 增加滚动区域top
    self.tableView.mh_insetT = MH_SCREEN_HEIGHT;
    
    // ⚠️ FBI Warning:
    // Xcode Version 11.4.1 设置animated: NO 也不好使 总之下面这两个方法都不好使
    // Xcode Version 10.2.1 设置animated: NO 却好使
    /// 妥协处理:这里统一用 animated: Yes 来处理 然后控制动画时间 与 scrollView 的 setContentOffset:animated: 相近即可
    // 设置滚动位置 animated:YES 然后
    [self.tableView setContentOffset:CGPointMake(0, -MH_SCREEN_HEIGHT) animated:YES];
    /// 按照这个方式 会没有动画 tableView 会直接掉下去
    /// [self.tableView setContentOffset:CGPointMake(0, -MH_SCREEN_HEIGHT)];
    
    /// - [iphone – UIScrollview setContentOffset与非线性动画?](http://www.voidcn.com/article/p-glnejqrs-bsv.html)
    /// - [iphone – 更改setContentOffset的速度:animated:?](http://www.voidcn.com/article/p-bgupiewh-bsr.html)
    
} completion:^(BOOL finished) {
    /// code...
}];

Q6:下拉进入Refreshing的过渡动画结束(completion)后,重置tableViewcontentInsetcontentOffset和初始转态一致;这样方便上拉拖动时,只需要修改tableView.y的值即可,无需关注contentInsetcontentOffset的设置。
A6:代码如下:

/// 动画
[UIView animateWithDuration:MHPulldownAppletRefreshingDuration animations:^{

    // 增加滚动区域top
    self.tableView.mh_insetT = MH_SCREEN_HEIGHT;
    
    // ⚠️ FBI Warning:
    // Xcode Version 11.4.1 设置animated: NO 也不好使 总之下面这两个方法都不好使
    // Xcode Version 10.2.1 设置animated: NO 却好使
    /// 妥协处理:这里统一用 animated: Yes 来处理 然后控制动画时间 与 scrollView 的 setContentOffset:animated: 相近即可
    // 设置滚动位置 animated:YES 然后
    [self.tableView setContentOffset:CGPointMake(0, -MH_SCREEN_HEIGHT) animated:YES];
    /// 按照这个方式 会没有动画 tableView 会直接掉下去
    /// [self.tableView setContentOffset:CGPointMake(0, -MH_SCREEN_HEIGHT)];
    
    /// - [iphone – UIScrollview setContentOffset与非线性动画?](http://www.voidcn.com/article/p-glnejqrs-bsv.html)
    /// - [iphone – 更改setContentOffset的速度:animated:?](http://www.voidcn.com/article/p-bgupiewh-bsr.html)
    
} completion:^(BOOL finished) {
    /// 小tips: 这里动画完成后 将tableView 的 y 设置到 MH_SCREEN_HEIGHT - finalTop ; 以及 将contentInset 和 contentOffset 回到原来的位置
    /// 目的:后期上拉的时候 只需要改变tableView 的 y就行了
    CGFloat finalTop = self.contentInset.top;
    self.tableView.mh_y = MH_SCREEN_HEIGHT - finalTop;
   // 增加滚动区域top
    self.tableView.mh_insetT = finalTop;
   // 设置滚动位置
   [self.tableView setContentOffset:CGPointMake(0, -finalTop) animated:NO];
}];

Q7:下拉拖拽过程中,首次进入Pulling状态时,增加振动反馈。
A7:利用iOS 10.0提供的UIImpactFeedbackGenerator实现

/// 微信方案:不仅要下拉超过临界点 而且 保证是下拉状态:当前偏移量 > 上一次偏移量
if (self.state == MHRefreshStateIdle && -delta < normal2pullingOffsetY && offsetY < self.lastOffsetY) {
    // 转为即将刷新状态
    self.state = MHRefreshStatePulling;
    
    /// iOS 10.0+ 下拉增加振动反馈 https://www.jianshu.com/p/ef7eadfae188
    if (self.isFeedback) {
        /// 只震动一次
        self.feedback = NO;
        /// 开启振动反馈 iOS 10.0+
        UIImpactFeedbackGenerator *feedBackGenertor = [[UIImpactFeedbackGenerator alloc] initWithStyle:UIImpactFeedbackStyleLight];
        [feedBackGenertor impactOccurred];
    }
    
} 

三个球指示

下拉拖拽过程:即scrollView.isDragging == YES,该过程主要是:1、根据下拉偏移量设置该模块的整体高度(height)。 2、根据下拉偏移量处于那几个阶段,修改内部三个球的形变(transform)和透明度(alpha)。下拉偏移量变化逻辑如下:

  • 初始阶段 => 阶段一(60):三个球的alpha都为0
  • 阶段一(60) => 阶段二(90):左右两个球的的alpha都为0。中间球的的alpha1,并且其scale值从0 -> 2
  • 阶段二(90) => 阶段三(130):左边球的alpha1,且从中心点向左平移translation.x0 -> -16;中间球的的alpha1,并且其scale值从2 -> 1;右边球的alpha1,且从中心点向右平移translation.x0 -> 16
  • 阶段三(130) => 阶段四(240):三个球的alpha值从1 -> 0
  • 阶段四(240) => ∞:整个模块的alpha0

下拉松手 => 手指释放:若下拉状态由 Pulling --> Refreshing,下拉偏移量达到最大值屏幕高度,则模块高度为屏幕高度,由于其层级最高,为了不遮盖其他视图,需要设置自身alpha0

以上关键代码如下:

- (void)_handleOffset:(NSDictionary *)dictionary {
    
    CGFloat offset = [dictionary[@"offset"] doubleValue];
    MHRefreshState state = [dictionary[@"state"] doubleValue];
    
    ///
    if (state == MHRefreshStateRefreshing) {
        self.alpha = .0f;
    }else {
        self.alpha = 1.0f;
    }
 
    // 中间点相关
    CGFloat scale = 0.0;
    CGFloat alphaC = 0;
    
    // 右边点相关
    CGFloat translateR = 0.0;
    CGFloat alphaR = 0;
    
    // 左边点相关
    CGFloat translateL = 0.0;
    CGFloat alphaL = 0;
    
    if (offset > MHPulldownAppletCriticalPoint3){
        /// 超过这个 统一是 将自身隐藏
        self.alpha = .0f;
        
    } else if (offset > MHPulldownAppletCriticalPoint2) {
        // 第四阶段 1 - 0
        CGFloat step = 1.0 / (MHPulldownAppletCriticalPoint3 - MHPulldownAppletCriticalPoint2);
        double alpha = 1 - step * (offset - MHPulldownAppletCriticalPoint2);
        alpha = MAX(.0f, alpha);
        
        // 中间点阶段III: 保持scale 为1
        alphaC = alpha;
        scale = 1;
        
        // 右边点阶段III: 平移到最右侧
        alphaR = alpha;
        translateR = 16;
        
        // 左边点阶段III: 平移到最左侧
        alphaL = alpha;
        translateL = -16;
    } else if (offset > MHPulldownAppletCriticalPoint1) {
        CGFloat delta = MHPulldownAppletCriticalPoint2 - MHPulldownAppletCriticalPoint1;
        CGFloat deltaOffset = offset - MHPulldownAppletCriticalPoint1;
        
        // 中间点阶段II: 中间点缩小:2 -> 1
        CGFloat stepC = 1 / delta;
        alphaC = 1;
        scale = 2 - stepC * deltaOffset;
        
        // 右边点阶段II: 慢慢平移 0 -> 16
        CGFloat stepR = 16.0 / delta;
        alphaR = 1;
        translateR = stepR * deltaOffset;
        
        // 左边点阶段II: 慢慢平移 0 -> -16
        CGFloat stepL = -16.0 / delta;
        alphaL = 1;
        translateL = stepL * deltaOffset;
    } else if (offset > MHPulldownAppletCriticalPoint0) {
        CGFloat delta = MHPulldownAppletCriticalPoint1 - MHPulldownAppletCriticalPoint0;
        CGFloat deltaOffset = offset - MHPulldownAppletCriticalPoint0;
        
        // 中间点阶段I: 中间点放大:0 -> 2
        CGFloat step = 2 / delta;
        alphaC = 1;
        scale = 0 + step * deltaOffset;
    }
    
    self.centerBall.alpha = alphaC;
    self.centerBall.transform = CGAffineTransformMakeScale(scale, scale);
    
    self.leftBall.alpha = alphaL;
    self.leftBall.transform = CGAffineTransformMakeTranslation(translateL, 0);
    
    self.rightBall.alpha = alphaR;
    self.rightBall.transform = CGAffineTransformMakeTranslation(translateR, 0);
}

❗️❗️❗️细节处理如下👇:
Q1:该模块要监听微信首页传进来的偏移量状态,这里笔者将两者包装在一个字典中:offsetInfo = @{@"offset": xxx,@"state": ooo};这里利用RACRACObserve方法,这里千万不要设置为distinctUntilChanged,不然微量的变化,并不会触发监听事件。
A1:正确代码如下

@weakify(self);
/// Fixed bug: distinctUntilChanged 不需要,否则某些场景认为没变化 实际上变化了
RACSignal *signal = [RACObserve(self.viewModel, offsetInfo) skip:1];
[signal subscribeNext:^(NSDictionary *dictionary) {
    @strongify(self);
     /// code....
}];

下拉小程序容器

下拉拖拽过程:即scrollView.isDragging == YES,状态为IdlePulling,该过程主要是:根据传过来的偏移量是否超过临界点(130),来控制其自身alpha = offset > 130 ? 1.0 : .0。以及当偏移量超过130的条件下,来控制小程序模块alphascale;以及蒙版alpha值。这里分析一下此场景的逻辑。

  • 初始阶段(0) => 阶段一(130):整个模块的alpha0
  • 阶段一(130) => 阶段二(240):小程序模块alpha0 -> 0.3,以及scale.x0.6 -> 0.7scale.y0.4 -> 0.5蒙版alpha0 -> 0.3
  • 阶段二(240) => ∞:。整个模块的alpha1小程序模块alpha0.3,以及scale = {x: 0.7, y: 0.5}蒙版alpha0.3

下拉松手 => 手指释放:若下拉状态由 Pulling --> Refreshing,进行过渡动画,整个模块的alpha过渡到1小程序模块alpha过渡到1.0,以及scale = {x: 0.6, y: 0.5}过渡到scale = {x: 1.0, y: 1.0}蒙版alpha0.3过渡到0.6云层模块alpha0.3过渡到1.0。考虑到上拉时滚动条比较短,证明内容比较长,这里设置scrollViewcontentSize.height较大即可。即self.scrollView.contentSize = CGSizeMake(0, 20 * MH_SCREEN_HEIGHT);

关键代码如下:

#pragma mark - 事件处理Or辅助方法
- (void)_handleOffset:(CGFloat)offset state:(MHRefreshState)state {
    
    if (state == MHRefreshStateRefreshing) {
        /// 释放刷新状态
        [UIView animateWithDuration:MHPulldownAppletRefreshingDuration delay:0 options:UIViewAnimationOptionCurveLinear animations:^{
            /// Fixed Bug: 这里也得显示
            self.view.alpha = 1.0f;
            
            /// 小程序相关
            self.appletController.view.alpha = 1.0f;
            self.appletController.view.transform = CGAffineTransformMakeScale(1.0, 1.0);
            /// 蒙版相关
            self.darkView.alpha = .6f;
            /// 天气相关
            self.weatherView.alpha = 1.0f;
        } completion:^(BOOL finished) {
            /// 弄高点 形成滚动条短一点的错觉
            self.scrollView.contentSize = CGSizeMake(0, 20 * MH_SCREEN_HEIGHT);
        }];
    }else {
        /// 超过这个临界点 才有机会显示
        if (offset > MHPulldownAppletCriticalPoint2) {
            /// show
            self.view.alpha = 1.0f;
            
            /// 小程序View alpha 0 --> .3f
            CGFloat alpha = 0;
            CGFloat step = 0.3 / (MHPulldownAppletCriticalPoint3 - MHPulldownAppletCriticalPoint2);
            alpha = 0 + step * (offset - MHPulldownAppletCriticalPoint2);
            self.appletController.view.alpha = MIN(.3f, alpha);
            
            /// 小程序View scale 0 --> .1f
            CGFloat scale = 0;
            CGFloat step2 = 0.1 / (MHPulldownAppletCriticalPoint3 - MHPulldownAppletCriticalPoint2);
            scale =  0 + step2 * (offset - MHPulldownAppletCriticalPoint2);
            scale = MIN(.1f, scale);
            self.appletController.view.transform = CGAffineTransformMakeScale(0.6 + scale, 0.4 + scale);
            
            /// darkView alpha 0 --> .3f
            CGFloat alpha1 = 0;
            CGFloat step1 = 0.3 / (MHPulldownAppletCriticalPoint3 - MHPulldownAppletCriticalPoint2);
            alpha1 = 0 + step1 * (offset - MHPulldownAppletCriticalPoint2);
            self.darkView.alpha = MIN(.3f, alpha1);
        }else {
            self.view.alpha = .0f;
        }
    }
}

❗️❗️❗️细节处理如下👇:
Q1:初始情况下,小程序模块的缩放系数为scale = {x: 0.6, y: 0.4},但是默认情况是从中心点开始缩放,导致小程序模块的顶部不会处于屏幕顶部,显然不符合实际需要。
A1:只需要修改锚点(anchorPoint)位置即可,默认情况:anchorPoint = CGPointMake(.5, .5);所以只需要修改为顶部中间即可:anchorPoint = CGPointMake(.5, 0)。考虑到修改了view.layer.anchorPoint,会导致view.frame变化,这里内部细节大家请自行百度,这里只需要知道结论: 先设置锚点anchorPoint,再设置尺寸frame 即可。


// 先设置锚点,在设置frame
appletController.view.layer.anchorPoint = CGPointMake(0.5, 0);
appletController.view.frame = CGRectMake(0, 0, MH_SCREEN_WIDTH, height);
appletController.view.transform = CGAffineTransformMakeScale(0.6, 0.4);
appletController.view.alpha = .0f;

⬆️ 上拉阶段

在开始讲上拉逻辑之前,我们先分析一下小程序容器模块的页面布局和层级结构,首先,该模块存在以下子模块:

  • 小程序模块:展示用户最近使用的小程序。
  • 黑色蒙版:主要是上拉或下拉,修改其alpha值,来拖拽状态和方向,下拉时,alpha 增加,上拉时,alpha减少。
  • 云层模块:云层动态展示。
  • scrollView: 用于上拉滚动。

层级结构(从上到下): 小程序模块 --> 上拉UIScollView --> 云层模块 --> 黑色蒙版 --> 小程序容器模块.view

考虑到上拉过程中, 小程序模块云层模块y值,也会不停的上移,最大上移高度为屏幕的高度;这里就有个将小程序模块云层模块添加到谁身上的问题:上拉UIScrollView小程序容器模块.view

  • 方案一: 若添加在上拉UIScrollView 身上,由于小程序模块也能上拉和下拉,这样就会和上拉ScrollView的上拉或下拉手势冲突,当然,网上也有大量的解决手势冲突的方案。
  • 方案二: 若添加在小程序容器模块.view 身上,想比上面的方案,就不用担心手势冲突了,毕竟他们之间没有半毛钱关系。只需要监听scrollView的滚动,来设置他们的y`即可。真香!!

综上所述:笔者采用方案二代码如下:

/// 初始化子控件
- (void)_setupSubviews{
    
    /// 蒙版
    UIView *darkView = [[UIView alloc] init];
    darkView.backgroundColor = MHColorFromHexString(@"#1b1b2e");
    darkView.alpha = .0f;
    self.darkView = darkView;
    [self.view addSubview:darkView];
    
    /// 天气
    CGRect frame = CGRectMake(0, 0, MH_SCREEN_WIDTH, MH_SCREEN_HEIGHT);
    WHWeatherView *weatherView = [[WHWeatherView alloc] init];
    weatherView.frame = frame;
    [self.view addSubview:weatherView];
    self.weatherView = weatherView;
    weatherView.alpha = .0f;
    
    /// 滚动
    UIScrollView *scrollView = [[UIScrollView alloc] init];
    self.scrollView = scrollView;
    MHAdjustsScrollViewInsets_Never(scrollView);
    [self.view addSubview:scrollView];
    /// 高度为 屏高-导航栏高度 形成滚动条在导航栏下面
    scrollView.frame = CGRectMake(0, MH_APPLICATION_TOP_BAR_HEIGHT, MH_SCREEN_WIDTH, MH_SCREEN_HEIGHT-MH_APPLICATION_TOP_BAR_HEIGHT);
    scrollView.backgroundColor = [UIColor clearColor];
    scrollView.delegate = self;
    scrollView.contentInset = UIEdgeInsetsMake(0, 0, 0, 0);
    
    /// 设置减速
    //    scrollView.decelerationRate = 0.5f;

    /// 添加下拉小程序模块
    CGFloat height = MH_APPLICATION_TOP_BAR_HEIGHT + (102.0f + 48.0f) * 2 + 74.0f + 50.0f;
    MHPulldownAppletViewController *appletController = [[MHPulldownAppletViewController alloc] initWithViewModel:self.viewModel.appletViewModel];
    /// 小修改: 之前是添加在 scrollView , 但是 会存在手势滚动冲突 当然也是可以解决的,但是笔者懒得很,就将其添加到 self.view
    //    [scrollView addSubview:appletController.view];
    [self.view addSubview:appletController.view];
    [self addChildViewController:appletController];
    [appletController didMoveToParentViewController:self];
    self.appletController = appletController;
    
    // 先设置锚点,在设置frame
    appletController.view.layer.anchorPoint = CGPointMake(0.5, 0);
    appletController.view.frame = CGRectMake(0, 0, MH_SCREEN_WIDTH, height);
    appletController.view.transform = CGAffineTransformMakeScale(0.6, 0.4);
    appletController.view.alpha = .0f;
}

处于小程序容器模块的从屏幕底部上拉拖拽到屏幕顶部的过程,等效于 处于微信首页模块屏幕顶部下拉拖拽到屏幕底部的过程的镜像。具体逻辑如下:

  • 相同条件:scrollview.isDragging == YES(未松手);假设屏幕高度为 = 736;假设下拉为正方向,即产生的偏移量(offset)为正数;上拉为负方向,即产生的偏移量(offset)为负数
  • 下拉到屏幕底部的拖拽过程:tableView.contentOffset.y0 ==> -736;产生的偏移量(offset0)从0 ==> 736;传给各模块的偏移量(offset1)从0 ==> 736(即:offset1 = offset0),然后各模块监听偏移量(offset1)的变化,处理自身的逻辑和样式变化。
  • 上拉到屏幕顶部的拖拽过程:scrollView.contentOffset.y0 ==> 736;产生的偏移量(offset0)从0 ==> -736。由于已处于上拉模块,证明各模块的偏移量(offset1)已处于下拉最大值:736,所以此时传给各模块的偏移量(offset1)从736 ==> 0(即:offset1 = 736 + offset0),然后各模块监听偏移量(offset1)的变化,处理自身的逻辑和样式变化

通俗理解:默认情况下,我们手指从scrollView的顶部下拉一段距离,scrollView的内容会跟着偏移一段距离;一旦手指释放后,scrollView的内容会自动回弹到scrollView顶部。而这种松手自动回弹到顶部的过程,就等效于上面上拉到屏幕顶部的拖拽过程

所以,处于小程序容器模块的从屏幕底部上拉拖拽到屏幕顶部的过程,涉及到微信首页模块的UI变化,这里笔者就不多逼逼了,大家逆推即可。

小程序容器模块

上拉拖拽过程(未松手 Pulling 或 Idle )逻辑如下:

  • 判断上拉状态(Pulling 或 Idle)。
  • 蒙版的alpha0.6 ==> 0云层模块小程序模块alpha1.0 ==> 0,以及frame.origin.y0 ==> -736
  • 回调offsetstate微信首页模块。

上拉拖拽过程(松手 Pulling => Refreshing)逻辑如下:

  • 回调offsetstate微信首页模块,让其以及其子模块,动画过渡到下拉初始状态Idle
  • 小程序容器模块以及其子模块,动画过渡到下拉初始状态Idle。特别提醒:这里要分清动画中动画后的逻辑处理:
    • 动画中:蒙版的alpha动画过渡到0云层模块小程序模块alpha 动画过渡到 0,以及frame.origin.y过渡到-736
    • 动画后: 动画完成后,需要设置:小程序容器模块alpha = 0.0云层模块小程序模块的``frame.origin.y = 0,设置小程序模块的缩放系数为CGAffineTransformMakeScale(0.6, 0.4),以及将小程序模块中的搜索框隐藏;云层模块alpha = 0.0;设置上拉ScrollViewcontentSizecontentOffset分别为CGSizeZeroCGPointZero`

上拉拖拽过程(松手 => Idle),比如,先上拉拖拽410的距离,紧接着下拉拖拽到400的距离,然后松手,这种场景并不会进入到Refreshing状态,而是进入Idle状态。逻辑如下:

  • 上拉scrollView400滚到0,即滚动到顶部,产生的偏移量(offset0)从-400 ==> 0。此时传给微信首页模块的的偏移量(offset1)从336 ==> 736(即:offset1 = 736 + offset0)。

具体关键代码如下:

#pragma mark - UIScrollViewDelegate
- (void)scrollViewWillBeginDragging:(UIScrollView *)scrollView {
    /// 开始拖拽
    self.dragging = YES;
    
    /// 关掉定时器
    [self _stopTimer];
}

- (void)scrollViewDidEndDragging:(UIScrollView *)scrollView willDecelerate:(BOOL)decelerate{
    
    /// 结束拖拽
    self.dragging = NO;
    // decelerate: YES 说明还有速度或者说惯性,会继续滚动 停止时调用scrollViewDidEndDecelerating/scrollViewDidScroll
    // decelerate: NO  说明是很慢的拖拽,没有惯性,不会调用 scrollViewDidEndDecelerating/scrollViewDidScroll
    if (!decelerate) {
        
        /// 非释放状态 需要手动 滚动到最顶部
        if (self.state != MHRefreshStatePulling) {
            [self _startTimer];
        }else {
            /// 手动调用
            [self scrollViewDidScroll:scrollView];
        }
    }else {
        /// 非释放状态 需要手动 滚动到最顶部
        if (self.state != MHRefreshStatePulling) {
            [self _startTimer];
        }
    }
    
    
}

/// Fixed Bug:scrollView.isDragging/isTracking 手指离开屏幕 可能还是会返回 YES 巨坑
/// 解决方案: 自己控制 dragging 状态, 方法如上
- (void)scrollViewDidScroll:(UIScrollView *)scrollView{
    
    /// 是否下拉
    BOOL isPulldown = NO;
    
    /// 获取偏移量
    CGFloat offsetY = scrollView.mh_offsetY;
    
    /// 这种场景 设置scrollView.contentOffset.y = 0 否则滚动条下拉 让用户觉得能下拉 但是又没啥意义 体验不好
    if (offsetY < -scrollView.contentInset.top) {
        scrollView.contentOffset = CGPointMake(0, -scrollView.contentInset.top);
        offsetY = 0;
        isPulldown = YES;
    }
    
    ///  微信只要滚动 结束拖拽 就立即进入刷新状态
    // 在刷新的refreshing状态 do nothing...
    if (self.state == MHRefreshStateRefreshing) {
        return;
    }
    
    /// 计算偏移量 负数
    CGFloat delta = -offsetY;
    
    // 如果正在拖拽
    if (self.isDragging) {
        
        CGFloat progress = MAX(MH_SCREEN_HEIGHT - offsetY, 0) / MH_SCREEN_HEIGHT;
        
        /// 更新 self.darkView.alpha 最大也只能拖拽 屏幕高
        self.darkView.alpha = 0.6 * progress;
        
        /// 更新 天气/小程序 的Y 和 alpha
        self.weatherView.mh_y = self.appletController.view.mh_y = delta;
        self.weatherView.alpha = self.appletController.view.alpha = 1.0f * progress;
        
        /// 必须是上拉
        if (self.state == MHRefreshStateIdle && (offsetY > self.lastOffsetY || isPulldown )) {
            // 转为即将刷新状态
            self.state = MHRefreshStatePulling;
        }else if (self.state == MHRefreshStatePulling && (offsetY <= self.lastOffsetY)){
            self.state = MHRefreshStateIdle;
        }
        
        /// 回调数据
        !self.viewModel.callback?:self.viewModel.callback( @{@"offset": @(delta), @"state": @(self.state)});
    } else if (self.state == MHRefreshStatePulling) {
        /// 进入帅新状态
        self.state = MHRefreshStateRefreshing;
    }
    
    
    /// 记录
    self.lastOffsetY = offsetY;
}
/**
 */
#pragma mark - Setter & Getter
- (void)setState:(MHRefreshState)state {
    MHRefreshState oldState = self.state;
    if (state == oldState) return;
    _state = state;
    
    // 根据状态做事情
    if (state == MHRefreshStateIdle) {
        if (oldState != MHRefreshStateRefreshing) return;
        
        // 恢复inset和offset
        [UIView animateWithDuration:.4f animations:^{
            /// 更新 天气/小程序 的Y
            self.weatherView.mh_y = self.appletController.view.mh_y = -MH_SCREEN_HEIGHT;
            
            self.darkView.alpha = .0f;
            
            self.weatherView.alpha = self.appletController.view.alpha = .0f;
            
        } completion:^(BOOL finished) {
            ///  --- 动画结束后做的事情 ---
            /// 隐藏当前view
            self.view.alpha = .0f;
            
            /// 重新调整 天气、小程序 的 y 值
            self.weatherView.mh_y = self.appletController.view.mh_y = 0;
            
            /// 重新将scrollView 偏移量 置为 0
            self.scrollView.contentOffset = CGPointZero;
            self.scrollView.contentSize = CGSizeZero;
            
            /// 重新设置 小程序view的缩放量
            self.appletController.view.transform = CGAffineTransformMakeScale(0.6, 0.4);
            [self.appletController resetOffset];
            
            /// 配置天气类型
            static NSInteger type = 0;
            type = (type + 1) % 5;
            /// 天气动画;
            [self.weatherView showWeatherAnimationWithType:type];
            self.weatherView.alpha = .0f;
            
        }];
    } else if (state == MHRefreshStateRefreshing) {
        dispatch_async(dispatch_get_main_queue(), ^{
            /// 传递状态
            /// 回调数据 offset info
            !self.viewModel.callback?:self.viewModel.callback( @{@"offset": @(-MH_SCREEN_HEIGHT), @"state": @(self.state)});

            /// 自身也进入空闲状态
            self.state = MHRefreshStateIdle;
        });
    }
}

❗️❗️❗️细节处理如下👇:
Q1:上拉松手检测,下拉时我们通过scrollView.isDragging == NO证明用户松手了;但是上拉时scrollView.isDragging/isTracking,松手了都依然是YES
A1:解决方法,监听UIScrollViewDelegate开始拖拽结束拖拽的两大代理方法即可。

#pragma mark - UIScrollViewDelegate
- (void)scrollViewWillBeginDragging:(UIScrollView *)scrollView {
    /// 开始拖拽
    self.dragging = YES;
}
- (void)scrollViewDidEndDragging:(UIScrollView *)scrollView willDecelerate:(BOOL)decelerate{   
    /// 结束拖拽
    self.dragging = NO; 
}

Q2:上拉拖拽Pulling状态检测。微信官方做法如下:

  • 如果scrollView内容处于最顶部,即scrollView.contentOffset.y == 0,紧接着下拉,理论上scrollView.contentOffset.y会小于0,这种情况会进入Pulling状态,当然,这种情况,微信会重置scrollView.contentOffset.y会等于0
  • 如果scrollView上拉,即scrollView.contentOffset.y > 0,并且保证是上拉情况,即当前scrollView.contentOffset.y大于上一次的 scrollView.contentOffset.y

A2:处理方案如下:

/// Fixed Bug:scrollView.isDragging/isTracking 手指离开屏幕 可能还是会返回 YES 巨坑
/// 解决方案: 自己控制 dragging 状态, 方法如上
- (void)scrollViewDidScroll:(UIScrollView *)scrollView{
    
    /// 是否下拉
    BOOL isPulldown = NO;
    
    /// 获取偏移量
    CGFloat offsetY = scrollView.mh_offsetY;
    
    /// 这种场景 设置scrollView.contentOffset.y = 0 否则滚动条下拉 让用户觉得能下拉 但是又没啥意义 体验不好
    if (offsetY < -scrollView.contentInset.top) {
        scrollView.contentOffset = CGPointMake(0, -scrollView.contentInset.top);
        offsetY = 0;
        isPulldown = YES;
    }
    
    ///  微信只要滚动 结束拖拽 就立即进入刷新状态
    // 在刷新的refreshing状态 do nothing...
    if (self.state == MHRefreshStateRefreshing) {
        return;
    }
    
    /// 计算偏移量 负数
    CGFloat delta = -offsetY;
    
    // 如果正在拖拽
    if (self.isDragging) {
        
        CGFloat progress = MAX(MH_SCREEN_HEIGHT - offsetY, 0) / MH_SCREEN_HEIGHT;
        
        /// 更新 self.darkView.alpha 最大也只能拖拽 屏幕高
        self.darkView.alpha = 0.6 * progress;
        
        /// 更新 天气/小程序 的Y 和 alpha
        self.weatherView.mh_y = self.appletController.view.mh_y = delta;
        self.weatherView.alpha = self.appletController.view.alpha = 1.0f * progress;
        
        /// 必须是上拉
        if (self.state == MHRefreshStateIdle && (offsetY > self.lastOffsetY || isPulldown )) {
            // 转为即将刷新状态
            self.state = MHRefreshStatePulling;
        }else if (self.state == MHRefreshStatePulling && (offsetY <= self.lastOffsetY)){
            self.state = MHRefreshStateIdle;
        }
        
        /// 回调数据
        !self.viewModel.callback?:self.viewModel.callback( @{@"offset": @(delta), @"state": @(self.state)});
    } else if (self.state == MHRefreshStatePulling) {
        /// 进入帅新状态
        self.state = MHRefreshStateRefreshing;
    }
   
    /// 记录
    self.lastOffsetY = offsetY;
}

Q3:上拉拖拽,松手进入Idle的处理逻辑。即:先上拉拖拽410的距离,紧接着下拉拖拽到400的距离,然后松手。

  • 微信官方做法:丝滑缓慢的从scrollView.contentOffset.y = 400滚动到最顶部scrollView.contentOffset.y = 0。注意两个点:丝滑缓慢

A3.1:相信大家的第一想法就是:利用setContentOffset:animated:来实现,只需要在结束拖拽停止减速的代理中调用即可,

- (void)scrollViewDidEndDragging:(UIScrollView *)scrollView willDecelerate:(BOOL)decelerate{
    
    /// 结束拖拽
    self.dragging = NO;
    // decelerate: YES 说明还有速度或者说惯性,会继续滚动 停止时调用scrollViewDidEndDecelerating/scrollViewDidScroll
    // decelerate: NO  说明是很慢的拖拽,没有惯性,不会调用 scrollViewDidEndDecelerating/scrollViewDidScroll
    if (!decelerate) {
        /// 非释放状态 需要手动 滚动到最顶部
        if (self.state != MHRefreshStatePulling) {
            [scrollView setContentOffset:CGPointMake(0,0) animated:YES];
        }else {
            /// 手动调用
            [self scrollViewDidScroll:scrollView];
        }
    }
}

- (void)scrollViewDidEndDecelerating:(UIScrollView *)scrollView {
    if (self.state != MHRefreshStatePulling) {
        [scrollView setContentOffset:CGPointMake(0,0) animated:YES];
    }
}

Q3.1:上面A3.1的方案,虽然动画滚动到最顶部,但是还是存在以下几个问题:

  • 结束拖拽的代理中,并且decelerate == NO场景下,会丝滑的滚动到最顶部,但不是缓慢过渡,而是快速过渡,毕竟setContentOffset:animated:的动画时间不能手动设置。
  • 结束拖拽的代理中,并且decelerate == YES场景下,说明scrollView还有向下滚动的趋势(惯性),我们选择在scrollViewDidEndDecelerating中滚动到顶部, 过渡状态由慢到快,不满足丝滑的条件以及缓慢的条件,

A3.2: 针对Q3.1的问题,衍生出利用UIView动画代替setContentOffset:animated:YES的场景,毕竟UIView的动画时间是可以手动设定的。方案如下:

- (void)scrollViewDidEndDragging:(UIScrollView *)scrollView willDecelerate:(BOOL)decelerate{
    /// 结束拖拽
    self.dragging = NO;
    // decelerate: YES 说明还有速度或者说惯性,会继续滚动 停止时调用scrollViewDidEndDecelerating/scrollViewDidScroll
    // decelerate: NO  说明是很慢的拖拽,没有惯性,不会调用 scrollViewDidEndDecelerating/scrollViewDidScroll
    if (!decelerate) {
        /// 非释放状态 需要手动 滚动到最顶部
        if (self.state != MHRefreshStatePulling) {
            [UIView animateWithDuration:4 animations:^{
                [scrollView setContentOffset:CGPointZero];
            }];
        }else {
            /// 手动调用
            [self scrollViewDidScroll:scrollView];
        }
    }
}

- (void)scrollViewDidEndDecelerating:(UIScrollView *)scrollView {
    /// 因为这里已经减速完成了 所以动画时间要更久
    /// 非释放状态 需要手动 滚动到最顶部
    if (self.state != MHRefreshStatePulling) {
        [UIView animateWithDuration:10 animations:^{
            [scrollView setContentOffset:CGPointZero];
        }];
    }
}

Q3.2:上面A3.2的方案,虽然完美的解决了A3.1不够丝滑动画过快的痛点,当是还是存在以下些许不足:

  • 无法监听滚动过程中的偏移量(contentOffset)的变化,即:使用UIView动画后,无论在什么时候查询contentOffset的值,得到的都是动画的最终值CGPointZero
  • 由于上拉拖拽过程中,偏移量从0 ==> 130 这段滚动中,微信首页的导航栏的背景色由#FFFFFF 过渡到 #EDEDED,反之,如果我们用UIVIew动画,只能知道动画的最终值CGPointZero。导致导航栏一放手,导航就直接变成白色的过程,影响用户体验。

A3.3:为了保证下滑丝滑缓慢偏移量可监听等业务逻辑,这里采取的是NSTimer,来模拟先快后慢的下滑过程,在定时器事件回调中,不断设置scrollViewcontentOffset属性,以及回调offsetstate微信首页模块,从而通过监听偏移量的变化,来处理UI。代码逻辑如下:

#pragma mark - UIScrollViewDelegate
- (void)scrollViewWillBeginDragging:(UIScrollView *)scrollView {
   /// 开始拖拽
   self.dragging = YES;
   
   /// 关掉定时器
   [self _stopTimer];
}

- (void)scrollViewDidEndDragging:(UIScrollView *)scrollView willDecelerate:(BOOL)decelerate{
   
   /// 结束拖拽
   self.dragging = NO;
   // decelerate: YES 说明还有速度或者说惯性,会继续滚动 停止时调用scrollViewDidEndDecelerating/scrollViewDidScroll
   // decelerate: NO  说明是很慢的拖拽,没有惯性,不会调用 scrollViewDidEndDecelerating/scrollViewDidScroll
   if (!decelerate) {
       
       /// 非释放状态 需要手动 滚动到最顶部
       if (self.state != MHRefreshStatePulling) {
           [self _startTimer];
       }else {
           /// 手动调用
           [self scrollViewDidScroll:scrollView];
       }
   }else {
       /// 非释放状态 需要手动 滚动到最顶部
       if (self.state != MHRefreshStatePulling) {
           [self _startTimer];
       }
   }
}

/// 开始定时器
- (void)_startTimer {
   ///
   if (!self.timer && !self.timer.isValid && self.lastOffsetY > 0) {
       /// 获取当前拖拽结束d偏移量
       self.offsetValue = self.scrollView.mh_offsetY;
       
       /// 计时次数清零
       self.timerCount = 0;
       /// 模拟先快后慢 假设 快阶段:0.5s跑80%的距离 慢阶段:0.5s跑20%的距离
       NSTimeInterval interval = .01f;
       CGFloat count0 = 1.5 * 0.3/interval;
       CGFloat count1 = 1.5 * 0.7/interval;
       
       self.stepFastValue = self.offsetValue * 0.5/count0;
       self.stepSlowValue = self.offsetValue * 0.5/count1;
       
       self.timer = [YYTimer timerWithTimeInterval:interval target:self selector:@selector(_timerValueChanged:) repeats:YES];
   }
}

/// 关闭定时器 用户一旦开始拖拽 就关闭定时器
- (void)_stopTimer {
   if (self.timer && self.timer.isValid) {
       [self.timer invalidate];
       self.timer = nil;
   }
}


/// 定时器回调事件
- (void)_timerValueChanged:(YYTimer *)timer{
   /// 进来+1
   self.timerCount++;
   
   /// 设置步进值
   if (self.timerCount <= 1.5 * 0.3 / 0.01) {
       /// 快阶段
       self.offsetValue -= self.stepFastValue;
   }else {
       self.offsetValue -= self.stepSlowValue;
   }
   
   /// 滚动结束 关闭定时器
   if (self.offsetValue <= 0) {
       [timer invalidate];
       self.timer = nil;
       /// 归零
       self.offsetValue = .0f;
   }
   /// 正数
   CGFloat offset = self.offsetValue;
   
   /// 设置scrollView 的偏移量
   [self.scrollView setContentOffset:CGPointMake(0, offset)];
   
   CGFloat progress = MAX(MH_SCREEN_HEIGHT - offset, 0) / MH_SCREEN_HEIGHT;
   
   /// 更新 self.darkView.alpha 最大也只能拖拽 屏幕高
   self.darkView.alpha = 0.6 * progress;
   
   /// 更新 天气/小程序 的Y 和 alpha
   self.weatherView.mh_y = self.appletController.view.mh_y = -offset;
   self.weatherView.alpha = self.appletController.view.alpha = 1.0f * progress;
   
   /// 回调数据
   !self.viewModel.callback?:self.viewModel.callback( @{@"offset": @(-offset), @"state": @(self.state)});
}
微信模块

这里笔者主要讲一下,上拉拖拽过程中,导航栏的颜色渐变逻辑,且这个逻辑只发生在上拉阶段(0 -- 130),下拉无需考虑其颜色变化。方案其实很简单,监听上拉偏移量的变化,
然后不断修改导航栏背景色的RGB即可。关键代码如下:

/// 处理拖拽时导航栏背景色变化
/// 只处理上拉的逻辑 下拉忽略
/// offset: 偏移量。
- (void)_changeNavBarBackgroundColor:(CGFloat)offset{
   
   static NSDictionary *dict0;
   static NSDictionary *dict1;
   
   /// 导航栏颜色:#ededed --> #fffff
   if (!(dict0 && dict0.allKeys.count != 0)) {
       UIColor *color0 = MHColorFromHexString(@"#ededed");
       dict0 = @{@"red":@(color0.red), @"green": @(color0.green), @"blue":@(color0.blue)};
       
       UIColor *color1 = [UIColor whiteColor];
       dict1 = @{@"red":@(color1.red), @"green": @(color1.green), @"blue":@(color1.blue)};
   }
   
   CGFloat delta = fabs(offset);
   
   if (delta > MH_SCREEN_HEIGHT) {
       delta = MH_SCREEN_HEIGHT;
   }
   
   /// 进度 0 --> 1.0f
   /// 下拉 不修改导航栏颜色
   CGFloat progress = .0f;
   if (delta < MHPulldownAppletCriticalPoint2) {
       /// 上拉 0 ---> 100
       progress = 1 - delta/MHPulldownAppletCriticalPoint2;
   }
   /// 计算差值
   CGFloat red = ([dict0[@"red"] doubleValue] + progress * ([dict1[@"red"] doubleValue] - [dict0[@"red"] doubleValue])) * 255;
   CGFloat green = ([dict0[@"green"] doubleValue] + progress * ([dict1[@"green"] doubleValue] - [dict0[@"green"] doubleValue])) * 255;
   CGFloat blue = ([dict0[@"blue"] doubleValue] + progress * ([dict1[@"blue"] doubleValue] - [dict0[@"blue"] doubleValue])) * 255;
   self.navBar.backgroundView.backgroundColor = MHColor(red, green, blue);
}

三个小球指示

这里讲一下上拉释放,状态由Pulling --> Refreshing的过程,由于其内部需要监听偏移量的变化,来修改三个小球的样式,但是由于这里只能监听到偏移量的最终值0
所以,过渡动画过程中,我们根本看不到三个球的变化(即三个变成一个),仅仅只能见到三个小球,丝滑平移到屏幕顶部的过程。解决方案,同上面类似,利用定时器(NSTimer)来处理即可:

- (void)bindViewModel:(MHBouncyBallsViewModel *)viewModel {
   self.viewModel = viewModel;
   
   @weakify(self);
   /// Fixed bug: distinctUntilChanged 不需要,否则某些场景认为没变化 实际上变化了
   RACSignal *signal = [RACObserve(self.viewModel, offsetInfo) skip:1];
   [signal subscribeNext:^(NSDictionary *dictionary) {
       @strongify(self);
       
       CGFloat offset = [dictionary[@"offset"] doubleValue];
       BOOL animate = [dictionary[@"animate"] boolValue];
       
       if (animate) {
           
           if (!self.timer && !self.timer.isValid && self.lastOffset > MHPulldownAppletCriticalPoint0) {
               NSTimeInterval interval = .05f;
               CGFloat count = MHPulldownAppletRefreshingDuration/interval;
               self.stepValue = self.lastOffset/count;
               self.timer = [YYTimer timerWithTimeInterval:interval target:self selector:@selector(_timerValueChanged:) repeats:YES];
           }
           
       } else {
           /// 记录上一次数据
           self.lastOffset = offset;
           ///
           [self _handleOffset:dictionary];
       }
   }];
}

/// 定时器为
- (void)_timerValueChanged:(YYTimer *)timer
{
   self.lastOffset -= self.stepValue;
   if (self.lastOffset <= 0) {
       [timer invalidate];
       self.timer = nil;
   }
   CGFloat offset = MAX(0, self.lastOffset);
   [self _handleOffset: @{@"offset" : @(offset), @"state": @(MHRefreshStateIdle), @"animate": @NO}];
}

小程序模块

小程序模块的业务相对比较简单,仅仅作为展示层,但是还是有些比较细节拉满的点,可以和大家聊聊。

❗️❗️❗️细节处理:
Q1:默认场景和上拉动画结束,需要隐藏搜索栏。
A1:提供一个公用API,供外部调用

#pragma mark - Public Method
- (void)resetOffset {
    self.tableView.contentOffset = CGPointMake(0, 57.0f);
}

Q2:由于小程序模块的高度,没有屏幕高,如果下拉时,tableView内容会被超出(隐藏)。
A2:下拉时(offset < 0),设置tableViewclipsToBoundsYES;但是上拉时(offset > 0),设置tableViewclipsToBoundsNO

#pragma mark - UIScrollViewDelegate
- (void)scrollViewDidScroll:(UIScrollView *)scrollView{
   CGFloat offset = scrollView.contentOffset.y;
   /// 不裁剪子视图
   self.tableView.clipsToBounds = offset > 0;
}

Q3:在偏移量0 ~ 搜索栏高度 = 57.0f范围内,若手指释放时,处于下拉状态,则显示搜索栏;反之,处于上拉状态,则隐藏搜索栏
A3:处理逻辑如下:

/// 细节处理:
/// 由于要弹出 搜索模块,所以要保证滚动到最顶部时,要确保搜索框完全显示或者完全隐藏,
/// 不然会导致弹出搜索模块,然后收回搜索模块,会导致动画不流畅,影响体验,微信做法也是如此
- (void)scrollViewDidEndDecelerating:(UIScrollView *)scrollView{
   /// 注意:这个方法不一定调用 当你缓慢拖动的时候是不会调用的
   [self _handleSearchBarOffset:scrollView];
}

- (void)scrollViewWillBeginDragging:(UIScrollView *)scrollView {
   // 记录刚开始拖拽的值
   self.startDragOffsetY = scrollView.contentOffset.y;
}

- (void)scrollViewDidEndDragging:(UIScrollView *)scrollView willDecelerate:(BOOL)decelerate{
   // 记录刚开始拖拽的值
   self.endDragOffsetY = scrollView.contentOffset.y;
   // decelerate: YES 说明还有速度或者说惯性,会继续滚动 停止时调用scrollViewDidEndDecelerating
   // decelerate: NO  说明是很慢的拖拽,没有惯性,不会调用 scrollViewDidEndDecelerating
   if (!decelerate) {
       [self _handleSearchBarOffset:scrollView];
   }
   
   /// 处理结束后的回调
   [self _handleEndDraggingAction];
}
/// 处理搜索框显示偏移
- (void)_handleSearchBarOffset:(UIScrollView *)scrollView {
   // 获取当前偏移量
   CGFloat offsetY = scrollView.contentOffset.y;
   CGFloat searchBarH = 57.0f;
   /// 在这个范围内
   if (offsetY > -scrollView.contentInset.top && offsetY < (-scrollView.contentInset.top + searchBarH)) {
       // 判断上下拉
       if (self.endDragOffsetY > self.startDragOffsetY) {
           // 上拉 隐藏
           CGPoint offset = CGPointMake(0, -scrollView.contentInset.top + searchBarH);
           [self.tableView setContentOffset:offset animated:YES];
       } else {
           // 下拉 显示
           CGPoint offset = CGPointMake(0, -scrollView.contentInset.top);
           [self.tableView setContentOffset:offset animated:YES];
       }
   }
}

Q4:若在小程序模块上拉偏移量超过135.0f,手指释放后,则需要影藏小程序容器模块,类似于使小程序容器模块进入上拉释放,状态由Pulling --> Refreshing状态的逻辑。
A4:处理逻辑如下:

/// 小程序模块
- (void)scrollViewDidEndDragging:(UIScrollView *)scrollView willDecelerate:(BOOL)decelerate{
   // 记录刚开始拖拽的值
   self.endDragOffsetY = scrollView.contentOffset.y;
   // decelerate: YES 说明还有速度或者说惯性,会继续滚动 停止时调用scrollViewDidEndDecelerating
   // decelerate: NO  说明是很慢的拖拽,没有惯性,不会调用 scrollViewDidEndDecelerating
   if (!decelerate) {
       [self _handleSearchBarOffset:scrollView];
   }
   
   /// 处理结束后的回调
   [self _handleEndDraggingAction];
}
/// 处理结束拖拽的事件 135.0f
- (void)_handleEndDraggingAction {
   if (self.endDragOffsetY >= 135.0f) {
       /// 回调数据 直接回到主页
       !self.viewModel.callback ? : self.viewModel.callback(@{@"completed":@YES,@"delay":@NO});
   }
}

/// 小程序容器模块
/// 监听小程序的回调数据
/// completed: YES 回到主页 NO 不回到主页
self.viewModel.appletViewModel.callback = ^(NSDictionary *dictionary) {
   @strongify(self);
   
   BOOL completed = [dictionary[@"completed"] boolValue];
   BOOL delay = [dictionary[@"delay"] boolValue];
   
   if (completed) {
       /// 增加延迟,方便等到跳转到下一页 再回到主页
       if (delay) {
           self.delay = delay;
       }else {
           self.state = MHRefreshStateRefreshing;
       }
   }
};

Q5:下钻二级页面,关闭小程序模块,以及关闭时机问题。例如:点击搜索栏,进入小程序搜索模块,这种场景无需关闭小程序模块;而点击某个小程序(王者荣耀),则进入王者荣耀小程序,则需要关闭小程序模块
当然不能立即关闭,而是等小程序模块消失后viewDidDisappear,再去关闭,不然会导致push动画和关闭时的过渡动画共存,显得比较脏乱。
A5:处理逻辑如下:

- (void)viewDidDisappear:(BOOL)animated {
   [super viewDidDisappear:animated];
   
   /// 放在这里做处理 不然还是会看到动画...
   if (self.isDelay) {
       self.delay = NO;
       self.state = MHRefreshStateRefreshing;
   }
}
/// 监听小程序的回调数据
/// completed: YES 回到主页 NO 不回到主页
self.viewModel.appletViewModel.callback = ^(NSDictionary *dictionary) {
   @strongify(self);
   
   BOOL completed = [dictionary[@"completed"] boolValue];
   BOOL delay = [dictionary[@"delay"] boolValue];
   
   if (completed) {
       /// 增加延迟,方便等到跳转到下一页 再回到主页
       if (delay) {
           self.delay = delay;
       }else {
           self.state = MHRefreshStateRefreshing;
       }
   }
};

♥️ 期待

  1. 文章若对您有些许帮助,请给个喜欢♥️ ,毕竟码字不易;若对您没啥帮助,请给点建议💗,切记学无止境。
  2. 针对文章所述内容,阅读期间任何疑问;请在文章底部评论指出,我会火速解决和修正问题。
  3. GitHub地址:https://github.com/CoderMikeHe
  4. 源码地址:WeChat

☎️ 主页

GitHub 掘金 CSDN 知乎
点击进入 点击进入 点击进入 点击进入
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 194,088评论 5 459
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 81,715评论 2 371
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 141,361评论 0 319
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 52,099评论 1 263
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 60,987评论 4 355
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 46,063评论 1 272
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 36,486评论 3 381
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 35,175评论 0 253
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 39,440评论 1 290
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 34,518评论 2 309
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 36,305评论 1 326
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 32,190评论 3 312
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 37,550评论 3 298
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 28,880评论 0 17
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 30,152评论 1 250
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 41,451评论 2 341
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 40,637评论 2 335