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