⭐️ 概述
-
本文笔者将手把手带领大家像素级还原
微信下拉小程序的实现过程。尽量通过简单易懂的言语,以及配合关键代码,详细讲述该功能实现过程中所运用到的技术和实现细节,以及遇到问题后如何解决的心得体会。希望正有此功能需要的小伙伴们,能够通过阅读本文后,能快速将此功能真正运用到实际项目开发中去。 -
当然,笔者的实现方案不一定是微信官方的实现,毕竟
一千个观众眼中有一千个潘金莲,但是,不管黑猫白猫,能捉老鼠的就是好猫,若能够实现此功能,相信也是一个不错的方案。希望该篇文章能为大家提供一点思路,少走一些弯路,填补一些细坑。文章仅供大家参考,若有不妥之处,还望不吝赐教,欢迎批评指正。 -
源码地址:WeChat
🌈 预览

🔎 分析
📦 模块
-
三个球指示模块: 微信主页下拉时,用于指示用户的下拉处于哪个阶段。(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会回弹一点点,导致进入Refreshing的TableView动画过渡不够丝滑。
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;
}
Q4:TabBar动画问题。首先,下拉 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_HEIGHT 和 tableView.contentOffset.y = - MH_SCREEN_HEIGHT,但是,理想很丰满,现实很骨感,
我们可以将UIView的动画时间设置大一些,可以清楚的发现,内容页tableView是立即掉下去的,丝毫不见动画;当然,UIScrollView 也提供一个API动画滚动指定位置setContentOffset: animated:,
这里拓展一下:setContentOffset:和 setContentOffset:animated:的异同点:
setContentOffset:animated:这种方法,无论animated为YES还是NO, 都会等待scrollView的滚动结束以后才会执行,也就是当isDragging和isDecelerating为YES的时候,会等待滚动完成才执行上面的方法。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 )后,重置tableView的contentInset和contentOffset和初始转态一致;这样方便上拉拖动时,只需要修改tableView.y的值即可,无需关注contentInset和contentOffset的设置。
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。中间球的的alpha为1,并且其scale值从0->2。 - 阶段二(
90) => 阶段三(130):左边球的alpha为1,且从中心点向左平移translation.x从0->-16;中间球的的alpha为1,并且其scale值从2->1;右边球的alpha为1,且从中心点向右平移translation.x从0->16。 - 阶段三(
130) => 阶段四(240):三个球的alpha值从1->0。 - 阶段四(
240) => ∞:整个模块的alpha为0。
下拉松手 => 手指释放:若下拉状态由 Pulling --> Refreshing,下拉偏移量达到最大值屏幕高度,则模块高度为屏幕高度,由于其层级最高,为了不遮盖其他视图,需要设置自身alpha为0。
以上关键代码如下:
- (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};这里利用RAC的RACObserve方法,这里千万不要设置为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,状态为Idle或Pulling,该过程主要是:根据传过来的偏移量是否超过临界点(130),来控制其自身alpha = offset > 130 ? 1.0 : .0。以及当偏移量超过130的条件下,来控制小程序模块的alpha和scale;以及蒙版的alpha值。这里分析一下此场景的逻辑。
- 初始阶段(
0) => 阶段一(130):整个模块的alpha为0。 - 阶段一(
130) => 阶段二(240):小程序模块的alpha从0->0.3,以及scale.x从0.6->0.7,scale.y从0.4->0.5;蒙版的alpha从0->0.3 - 阶段二(
240) => ∞:。整个模块的alpha为1;小程序模块的alpha为0.3,以及scale = {x: 0.7, y: 0.5};蒙版的alpha为0.3;
下拉松手 => 手指释放:若下拉状态由 Pulling --> Refreshing,进行过渡动画,整个模块的alpha过渡到1;小程序模块的alpha过渡到1.0,以及scale = {x: 0.6, y: 0.5}过渡到scale = {x: 1.0, y: 1.0};蒙版的alpha从0.3过渡到0.6;云层模块的alpha从0.3过渡到1.0。考虑到上拉时滚动条比较短,证明内容比较长,这里设置scrollView的contentSize.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.y从0==>-736;产生的偏移量(offset0)从0==>736;传给各模块的偏移量(offset1)从0==>736(即:offset1 = offset0),然后各模块监听偏移量(offset1)的变化,处理自身的逻辑和样式变化。 - 上拉到屏幕顶部的拖拽过程:
scrollView.contentOffset.y从0==>736;产生的偏移量(offset0)从0==>-736。由于已处于上拉模块,证明各模块的偏移量(offset1)已处于下拉最大值:736,所以此时传给各模块的偏移量(offset1)从736==>0(即:offset1 = 736 + offset0),然后各模块监听偏移量(offset1)的变化,处理自身的逻辑和样式变化
通俗理解:默认情况下,我们手指从scrollView的顶部下拉一段距离,scrollView的内容会跟着偏移一段距离;一旦手指释放后,scrollView的内容会自动回弹到scrollView顶部。而这种松手自动回弹到顶部的过程,就等效于上面上拉到屏幕顶部的拖拽过程
所以,处于小程序容器模块的从屏幕底部上拉拖拽到屏幕顶部的过程,涉及到微信首页模块的UI变化,这里笔者就不多逼逼了,大家逆推即可。
小程序容器模块
上拉拖拽过程(未松手 Pulling 或 Idle )逻辑如下:
- 判断上拉状态(Pulling 或 Idle)。
- 蒙版的
alpha从0.6==>0;云层模块和小程序模块的alpha从1.0==>0,以及frame.origin.y从0==>-736。 - 回调
offset和state给微信首页模块。
上拉拖拽过程(松手 Pulling => Refreshing )逻辑如下:
- 回调
offset和state给微信首页模块,让其以及其子模块,动画过渡到下拉初始状态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;设置上拉ScrollView的contentSize和contentOffset分别为CGSizeZero和CGPointZero`
- 动画中:蒙版的
上拉拖拽过程(松手 => Idle),比如,先上拉拖拽410的距离,紧接着下拉拖拽到400的距离,然后松手,这种场景并不会进入到Refreshing状态,而是进入Idle状态。逻辑如下:
- 上拉
scrollView从400滚到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,来模拟先快后慢的下滑过程,在定时器事件回调中,不断设置scrollView的contentOffset属性,以及回调offset和state给微信首页模块,从而通过监听偏移量的变化,来处理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),下拉无需考虑其颜色变化。方案其实很简单,监听上拉偏移量的变化,
然后不断修改导航栏背景色的R、G、B即可。关键代码如下:
/// 处理拖拽时导航栏背景色变化
/// 只处理上拉的逻辑 下拉忽略
/// 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),设置tableView的clipsToBounds为YES;但是上拉时(offset > 0),设置tableView的clipsToBounds为NO。
#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;
}
}
};
❤️ 期待
- 文章若对您有些许帮助,请给个喜欢♥️ ,毕竟码字不易;若对您没啥帮助,请给点建议💗,切记学无止境。
- 针对文章所述内容,阅读期间任何疑问;请在文章底部评论指出,我会火速解决和修正问题。
- GitHub地址:github.com/CoderMikeHe
- 源码地址:WeChat
☎️ 主页
| GitHub | 简书 | CSDN | 知乎 |
|---|---|---|---|
| 点击进入 | 点击进入 | 点击进入 | 点击进入 |