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