在混合开发越来越普遍的今天,iOS原生页面里嵌一个WKWebView承载H5内容已经是标配做法。产品经理顺手提一句“加个下拉刷新吧”,看起来很简单,真上手做的时候却发现各种诡异:要么刷新控件死活不出来,要么网页一滚到顶部就疯狂触发刷新,要么干脆整个页面弹跳得像坏了一样。这篇就来系统讲清楚WKWebView下拉刷新冲突的成因,以及如何用UIScrollViewDelegate监听配合contentOffset控制把它彻底解决。

一、冲突是怎么产生的:先搞懂WKWebView的滚动结构
WKWebView内部有一个scrollView属性,类型是UIScrollView,网页内容的所有滚动行为其实都发生在这个滚动视图上。很多开发者第一反应是直接往webView.scrollView上挂一个MJRefresh的header,代码两行搞定,跑起来却发现问题一堆。
根本原因在于WKWebView对自身scrollView的接管程度非常高。它会根据自己的渲染逻辑动态调整contentSize、contentInsetAdjustmentBehavior,甚至在某些时机重置delegate。而当你把MJRefreshHeader或者UIRefreshControl挂上去后,刷新控件本质上是通过KVO监听contentOffset,并在用户下拉超过临界值时修改contentInset来实现停留效果的。两个“主人”同时操控一个scrollView,冲突自然就来了。
典型的表现有三种:一是网页内容还没滚到顶部,稍微下拉就触发了刷新动画;二是网页本身很短、不需要滚动时,下拉刷新完全失效,因为scrollView的alwaysBounceVertical默认关闭,根本没有弹性空间;三是iOS 13之后WKWebView在某些场景下会把delegate悄悄置空,导致你自己挂的代理方法全部失灵。
二、方案一:用UIScrollViewDelegate精确监听contentOffset变化
最稳妥的思路是不依赖第三方控件,自己通过UIScrollViewDelegate监听偏移量,手动判断什么时候该触发刷新。核心是两个代理方法:scrollViewWillBeginDragging记录用户开始拖拽的状态,scrollViewDidScroll实时拿到contentOffset.y。
判断逻辑很关键:只有当用户是“从顶部继续往下拉”时才算有效刷新手势。也就是说,在拖拽开始那一刻,contentOffset.y必须已经小于等于0(考虑到网页可能有bounce回弹,用-10做个容错更保险),否则就是普通的网页滚动,直接忽略。
@interface WebViewController () <WKNavigationDelegate, UIScrollViewDelegate>
@property (nonatomic, strong) WKWebView *webView;
@property (nonatomic, assign) BOOL isDraggingFromTop;
@property (nonatomic, assign) BOOL isRefreshing;
@end
@implementation WebViewController
- (void)viewDidLoad {
[super viewDidLoad];
WKWebViewConfiguration *config = [[WKWebViewConfiguration alloc] init];
self.webView = [[WKWebView alloc] initWithFrame:self.view.bounds configuration:config];
// 关键:网页内容不足一屏时也允许弹性滚动,否则下拉手势无效
self.webView.scrollView.alwaysBounceVertical = YES;
self.webView.scrollView.delegate = self;
[self.view addSubview:self.webView];
[self.webView loadRequest:[NSURLRequest requestWithURL:[NSURL URLWithString:@"https://ipipp.com"]]];
}
- (void)scrollViewWillBeginDragging:(UIScrollView *)scrollView {
// 拖拽起点在顶部附近(含回弹容错),才视为有效下拉手势
self.isDraggingFromTop = (scrollView.contentOffset.y <= 10);
}
- (void)scrollViewDidScroll:(UIScrollView *)scrollView {
if (!self.isDraggingFromTop || self.isRefreshing) {
return;
}
// 下拉超过80个点,触发刷新
if (scrollView.contentOffset.y <= -80) {
self.isRefreshing = YES;
[self startRefresh];
}
}
- (void)startRefresh {
// 冻结偏移量,防止刷新期间网页继续回弹跳动
[self.webView.scrollView setContentInset:UIEdgeInsetsMake(80, 0, 0, 0)];
dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(1.5 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{
[self.webView reload];
self.isRefreshing = NO;
});
}
- (void)scrollViewDidEndDecelerating:(UIScrollView *)scrollView {
// 网页加载完成后还原inset
if (!self.isRefreshing) {
[scrollView setContentInset:UIEdgeInsetsZero];
}
}
@end
这套方案的好处是控制粒度完全在自己手里,临界值、容错范围、刷新动画都能定制。缺点是需要处理的边界状态比较多,比如用户下拉到一半又松手回弹、刷新过程中用户又去拖动等,都要靠isRefreshing这类状态位兜底。另外别忘了处理scrollViewDidEndDragging:willDecelerate:,当willDecelerate为NO时inset还原逻辑也要走一遍。
三、方案二:继续用MJRefresh,但要做好参数适配
如果项目里已经统一使用MJRefresh,全部推翻重写成本太高,那么正确姿势是“挂上去之后做适配”。最常见的坑是给webView.scrollView加了header之后,网页还没滚到顶就触发刷新。解决办法是利用MJRefreshHeader的ignoredScrollViewContentInsetTop属性,或者干脆在scrollViewDidScroll里对偏移量做一次修正。
MJRefreshNormalHeader *header = [MJRefreshNormalHeader headerWithRefreshingBlock:^{
dispatch_async(dispatch_get_main_queue(), ^{
[self.webView reload];
});
}];
// WKWebView安全区域适配,避免顶部多出一截触发距离
header.ignoredScrollViewContentInsetTop = self.webView.scrollView.contentInsetAdjustmentBehavior == UIScrollViewContentInsetAdjustmentAutomatic
? 0 : 0;
header.stateLabel.hidden = NO;
[self.webView.scrollView.mj_header beginRefreshing];
[self.webView.scrollView setMj_header:header];
这里有几个细节必须注意。第一,reload要dispatch到主队列并且最好包一层延迟,否则在WebKit正在布局的瞬间刷新会导致偶现的crash。第二,iOS 13+系统里,如果页面里push了新的原生控制器再返回,WKWebView有时会重置scrollView.delegate,导致MJRefresh内部基于KVO的状态机错乱,表现为header卡在刷新中不消失。解决办法是自定义一个WKWebView子类,重写setDelegate:做拦截:
class CustomWebView: WKWebView {
// 拦截系统对delegate的重置,保住外层监听
private weak var externalDelegate: UIScrollViewDelegate?
override var scrollViewDelegate: UIScrollViewDelegate? {
get { externalDelegate }
set { externalDelegate = newValue }
}
}
实际上更实用的做法是用中间代理对象转发:创建一个代理转发类,同时实现respondsToSelector和forwardingTarget,把系统的调用转给WebKit内部,把你自己关心的方法留下处理。这种方案写起来繁琐,但一旦封装好,后面所有WKWebView页面都能复用。
四、contentOffset控制的进阶技巧与避坑清单
无论是哪种方案,最终都绕不开对contentOffset的精细控制。这里总结几条实战中踩出来的经验。首先是“滚到顶判断”不要只看y等于0。网页设置了window.scrollY有初始值、或者Web内容里有内滚容器时,外层scrollView可能在非零位置就已经到头了。更可靠的方式是通过执行一段JS获取真实滚动位置:
[self.webView evaluateJavaScript:@"window.scrollY + document.documentElement.scrollTop"
completionHandler:^(id result, NSError *error) {
CGFloat webScrollTop = [result doubleValue];
// webScrollTop接近0时,才允许外层下拉刷新生效
self.webCanRefresh = webScrollTop < 1.0;
}];
其次,刷新期间要防止用户二次拖拽导致偏移量跳变。可以在isRefreshing为YES时把scrollView.scrollEnabled临时置NO,或者用setContentOffset:animated:把位置钉死在刷新停留点。前者简单粗暴但体验略硬,后者要注意animated动画过程中用户手势会被打断,各有取舍。
最后列一份避坑清单:一,alwaysBounceVertical必须设为YES,否则短网页无法下拉;二,不要同时使用contentInsetAdjustmentBehavior的手动调整和安全区适配,二选一,否则顶部间距算两遍;三,Swift项目里给webView.scrollView.panGestureRecognizer加target做监听时要 weak 持有self,防止循环引用;四,如果H5页面自己实现了下拉刷新(比如通过touch事件模拟),务必和前端约定好只保留一端,否则会出现双层刷新动画叠加的怪象。把这些点都照顾到,WKWebView的下拉刷新就能做到和原生一样顺滑稳定。
WKWebView下拉刷新contentOffset修改时间:2026-09-07 06:32:42