本方案通过重写手势滚动代码,解决了 ScrollView
与 TableView
的嵌套滚动问题。同时通过障眼法的方案实现了 WKWebView
的高度自适应。
2023 - 8 - 31 解决滚动不丝滑的问题
在设置 contentOffset.y 时使用动画// 例如 UIView.animate(withDuration: 0.05, delay: 0, options: .init(arrayLiteral: [.beginFromCurrentState,.allowUserInteraction])) { [weak self] in self?.webView.scrollView.contentOffset.y = newOffsetY } UIView.animate(withDuration: 0.05, delay: 0, options: .init(arrayLiteral: [.beginFromCurrentState,.allowUserInteraction])) { [weak self] in self?.tableView.contentOffset.y = newOffsetY }
使用该动画会使滚动变得流畅,但会导致滚动时有一丢丢不跟手
2024-4-20
时隔半年,当我再打开这个页面,完全看不出这个页面的滚动是自己写的,感觉和原生的一样,完美!
Demo 在这 Demo
请使用 ViewController2 进行测试,Demo中并未加入【解决滚动不丝滑的问题】的代码,可自行添加
前边废话比较多,可以直接看 【第三节 - 方案实现】
觉得有用就点个赞支持一下,嘻
一、发现问题
最近测试告知我们的文章页面有几率出现显示不全的问题,拿自己的手机跑了几十遍才复现了一次。原因是 WKWebView
的高度设置错误。
我们的文章页面采用
TableView
嵌套WebView
的方式实现。
WebView
为第一个 Cell,将其设置为禁止滚动。然后将它的 frame
高度设置为与ContentSize
高度相同。这样可以实现滚动完文章后显示下边的 Cell 评论。而原本的逻辑为在 WebView
调用 DidFinish 后,将 WebView
所在的 Cell 高度设置为它内部 ContentSize
的高度,然后刷新一下 TableView
即可实现高度自适应。
但是出现问题的原因为 WebView
在调用 DidFinish 的时候有可能页面还没完全的渲染完毕,这时获取 ContentSize
就是错误的高度。
解决该问题最简单的方法就是通过 KVO
监听其内部 ScrollView
的 ContentSize
改变。每次改变就更新一下 WebView
的高度。
但这样还会产生另一个问题,由于我们修改的是 WebView
本身 Frame
的高度,当新页面的 ContentSize
高度低于 WebView
的高度时,KVO
就不会更新 ContentSize
。也就是说,当你进入一个特别长的网页后,再跳转到短一点的网页会出现底部留有大片空白的问题。
这个问题是非常影响用户体验的,所以我开始调研解决该问题的方法。
二、方案调研
先说最终的方案理论,首先将
WebView
的高度设置为手机屏幕的高度(有导航栏的去除导航栏高度),然后将WebView
设置为TableView
的子 View,或者设置为header
或Cell
都可以。然后我们自己控制WebView
和TableView
的滚动。因为进入页面后优先显示网页,当网页滚动到底部时列表才会滚动,在视觉上就会认为网页和列表是连在一起的,这样就达成了我们的目的。
所以我们需要专门去写一层手势控制,用手势去控制 WebView
和 TableView
的联动。
类似于分页的手势嵌套,不过不一样的是,分页的手势嵌套是
TableView
在ScrollView
上方,虽然表面上是TableView
在滚动,实际上两者的frame
都没有发生变化。而TableView
嵌套WebView
会出现WebView
滚动出屏幕的情况,这时候原本的手势就不会作用在WebView
上,就导致当我们TableView
滚动到顶部时,由于手势穿透没有作用于WebView
从而出现手势中断的问题。
方案调研流程,不想看的可以跳过。
首先我发现将 WebView
的 Size
设置为 Zero
后再刷新一下 WebView
,就可以重新监听到 ContentSize
的改变。但会造成一次屏幕的闪烁,不过影响不大,用户在视觉效果上会认为是网页的加载。我只需要知道页面什么时候跳转或者改变,就可以控制刷新的时机,于是我把调研的重点放在了监听页面的变化。
我认为,既然网页是在点击某个按钮后进行的跳转,那在网页加载的时候刷新一下高度不就可以了。然后我对 estimatedProgress
进行了监听,在页面加载完成后通过上面的方法刷新一下 WebView
。
经过实验,这种方式确实可以实现高度的修改。但又出现了一个新的问题,大部分的页面都是通过 URL 的跳转完成的,监听页面加载确实没有问题。但还有一种是在页面内进行的分页,页面内部进行上一页下一页的跳转时,依然会造成高度变高后无法缩小的问题,并且由于并没有发生 URL 跳转我们也无法监听到。
所以这种方案还有些缺陷。
于是我想,如果网页在点击按钮跳转时通过 js
回调给我们呢,经过询问发现我们的所有文章网页都是通过第三方工具生成的,也就是说没办法在里边增加 js
回调,那么这个方案也不大行。
既然网页不能回调给我,那我能不能对网页中的按钮添加监听呢,当网页加载完毕后对所有的按钮增加监听,当用户点击按钮时进行 js
回调,收到回调后我再刷新页面。有了这个想法后我去查找相关的代码,找到了注册 js
标签监听的方法,于是我添加对 <button>
标签的监听。
当我满心欢喜以为要成功时又出现了新问题,有些按钮监听不到。我打开网页调试工具发现我想的太简单了,网页中用各种标签做跳转 <a>
, <div>
, <img>
等,我不能监听所有标签的点击事件,那样点击一个标签就会闪一下,严重影响用户体验也会出现其他奇怪的bug。
之后又对 WebView
进行遍历查看层级,私有属性之类的,总之也走了不少弯路,最后想到了这种手势嵌套的方案。
三、方案实现
既然要自己写手势滚动,就需要先禁用 WebView
和 TableView
的滚动功能。然后创建一个新的 View
我们称他为 contentView
, 并设置其高度与 TableView
相同。然后 TableView
放在 contentView
的上方,或者设置为子视图也可以。
然后我们为 contentView
添加 PanGesture
手势,由于上方两个 View
的滚动手势都被禁用了,所以自然 contentView
会响应拖动手势。
之后我们分析一下滚动行为,首先进入页面是显示网页,那么在滚动时,如果是向下滚动(即 offsetY 增加),就应该当 WebView
滚动到底部后 TableView
才允许滚动。如果是向上滚动(即 offsetY )减少,就应当 TableView
滚动到顶部后 WebView
才允许滚动。
所以我们在手势代理的 Began
状态中需要获取基础数据。包括手势的起点和起始的 offsetY
,因为我们需要通过新值减去旧值判断滚动的方向(如果不是处理嵌套滚动的话,不需要判断方向)。
self.tableView.bounces = false
self.tableView.isScrollEnabled = false
self.tableView.showsVerticalScrollIndicator = false
self.tableView.showsHorizontalScrollIndicator = false
self.webView.scrollView.bounces = false
self.webView.scrollView.isScrollEnabled = false
self.webView.scrollView.showsVerticalScrollIndicator = false
self.webView.scrollView.showsHorizontalScrollIndicator = false
// 长按手势,主要控制滚动与惯性, self.referenceView 实际上就是 contentView,由于封装了代码,所以修改了名称为 referenceView ,后边也都是一样
let panGesture = UIPanGestureRecognizer(target: self, action: #selector(panGesture(_:)))
self.referenceView.addGestureRecognizer(panGesture)
/// 手势开始
func gestureBegan(_ gesture: UIPanGestureRecognizer) {
// 起始值,在后边使用时就变成了旧值,所以起名为old
oldPanGetstureY = gesture.location(in: self.referenceView).y
tableViewOldOffsetY = tableView.contentOffset.y
webViewOldOffsetY = webView.scrollView.contentOffset.y
}
@objcMembers
public class YTReferenceView: UIView {
var touchBeganCallBack: (() -> Void)?
public override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
touchBeganCallBack?()
}
}
之后我们在监听手势的移动,通过手势移动计算 offsetY
的移动。
/// 手势改变
func gestureChanged(_ gesture: UIPanGestureRecognizer) {
// 设置滚动时不响应tabview,因为响应的话会出现在滚动时点击 tableView 调用 didcell 的方法
tableViewUserInteractionEnabledState(false)
// 当前的位置
let currentY = gesture.location(in: self.referenceView).y
// 手势移动差值(注意正负值是与 offsetY 相反的)
let dValue = currentY - oldPanGetstureY
// 确定滚动方向
if currentY < oldPanGetstureY { // tableView offsetY 增加
if webViewOldOffsetY >= webViewMaxOffsetY { // webView 已经滚到底部,tabview 进行滚动
setScrollOffsetY(dValue: dValue, isWebView: false)
}else { // webView 没滚动到底部,webView 进行滚动
setScrollOffsetY(dValue: dValue, isWebView: true)
}
}else { // tableView offsetY 减少或不变
if tableViewOldOffsetY <= tableViewBeganOffsetY { // tableView 已经滚到顶部,webView 进行滚动
setScrollOffsetY(dValue: dValue, isWebView: true)
}else { // tableView 没滚到顶部, tableView 进行滚动
setScrollOffsetY(dValue: dValue, isWebView: false)
}
}
// 设置新的旧值
oldPanGetstureY = currentY
}
// 设置滚动 OffsetY
func setScrollOffsetY(dValue: CGFloat, isWebView: Bool) {
// 判断是谁在滚动,就使用谁的数据
let oldOffsetY = isWebView ? webViewOldOffsetY : tableViewOldOffsetY
let beganOffsetY = isWebView ? webViewBeganOffsetY : tableViewBeganOffsetY
let maxOffsetY = isWebView ? webViewMaxOffsetY : tableViewMaxOffsetY
// 计算滚动后的值
var newOffsetY = oldOffsetY - dValue
newOffsetY = newOffsetY >= beganOffsetY ? newOffsetY : beganOffsetY
if isWebView {
// 限制最大值
newOffsetY = newOffsetY >= maxOffsetY ? maxOffsetY : newOffsetY
// 修改 webView offsetY
webView.scrollView.contentOffset.y = newOffsetY
// 重新记录当前 offsetY
webViewOldOffsetY = newOffsetY
}else {
if newOffsetY >= maxOffsetY { // 弹起最大150
// 设置底部允许弹性动画(也就是允许超出一部分 offsetY,如果不允许的话上拉加载可能不起作用)
if bottomBounces {
// 超出最大值进度,设置超出的越多,移动的越慢,最大超出 150 offsetY
let progress = (((maxOffsetY + 150) - tableViewOldOffsetY) / 150) * 0.3
// 偏移量
let value = (newOffsetY - tableViewOldOffsetY) * progress
newOffsetY = tableViewOldOffsetY + value
}else {
// 判断是否最大偏移量
newOffsetY = newOffsetY >= maxOffsetY ? maxOffsetY : newOffsetY
}
}
tableView.contentOffset.y = newOffsetY
tableViewOldOffsetY = newOffsetY
}
}
这样我们就实现了 WebView
与 TableView
的联动滚动。
但是还没结束,现在只是实现了滚动功能,为了和原生滚动相似,我们还需要实现惯性滚动功能。惯性滚动功能使用的是 UIDynamic
中的 UIDynamicItemBehavior
。当滚动手势结束时,我们获取当前的手势速度,通过手势速度为一个隐藏的对象施加一个力,通过对该对象移动位置的监听来计算 offsetY
的滚动距离。
我们先创建一个物理动画控制器以及一个隐藏的对象。
// 这里的 self.referenceView 指的是 contentView。 因为我封装了代码,所以这里更换了变量名
private lazy var animator = UIDynamicAnimator(referenceView: self.referenceView)
private lazy var item = YTItemBehavior()
class YTItemBehavior: NSObject, UIDynamicItem {
var center: CGPoint
var bounds: CGRect
var transform: CGAffineTransform
override init() {
bounds = .init(x: 0, y: 0, width: 1, height: 1)
center = .zero
transform = .identity
}
}
// 我们需要在拖动开始时移除所有动画,并获取 began 时 item 的 Center 值
/// 手势开始
func gestureBegan(_ gesture: UIPanGestureRecognizer) {
// 需要移除动画是因为如果用户在惯性滚动中进行非惯性滚动的手势拖动,会出现滚动冲突现象
animator.removeAllBehaviors()
itemCenterY = item.center.y
// 起始值,在后边使用时就变成了旧值,所以起名为old
oldPanGetstureY = gesture.location(in: self.referenceView).y
tableViewOldOffsetY = tableView.contentOffset.y
webViewOldOffsetY = webView.scrollView.contentOffset.y
}
由于惯性动画是当手指离开屏幕时才开始的,所以我们将惯性动画的代码写在 end
状态中。
/// 手势结束
func gestureEnded(_ gesture: UIPanGestureRecognizer) {
// 计算手势滑动速度
let pointY = gesture.velocity(in: self.referenceView).y
if abs(pointY) >= 180 { // 设置滑动速度大于180才执行惯性动画,否则会出现用户轻轻挪动一下就有惯性
// 创建物理运动对象
let behavior = UIDynamicItemBehavior(items: [item])
// 设置阻力 这个自己看着来吧,我试了1.5到2.2,感觉都差不多,最终还是选择听取别人的建议2.0
behavior.resistance = 2.0
// 添加一个Y轴方向的力
behavior.addLinearVelocity(.init(x: 0, y: pointY), for: item)
// 监听运动回调
behavior.action = { [weak self] in // 根据惯性进行滚动
guard let weakSelf = self else { return }
// 计算新值与旧值的差值,就是 OffsetY 的值
let dValue = weakSelf.item.center.y - weakSelf.oldItemCenterY
// 即将结束惯性
if abs(dValue) < 0.02 { // 当偏移量的绝对值小于 0.02时,证明惯性即将结束。由于没有监听屋里状态停止的方法,只能找一个近似值
// 调用即将停止的方法
weakSelf.behaviorWillEndAnimation()
}else {
// 设置 tableView 允许响应手势
weakSelf.tableViewUserInteractionEnabledState(false)
}
// 确定滚动方向
if weakSelf.item.center.y < weakSelf.oldItemCenterY { // tableView offsetY 增加
if weakSelf.webViewOldOffsetY >= weakSelf.webViewMaxOffsetY { // webView 滚到底部,tabview 滚动
weakSelf.setScrollOffsetY(dValue: dValue, isWebView: false)
// tableView 滚动到底部时,结束惯性
if weakSelf.tableViewOldOffsetY >= weakSelf.tableViewMaxOffsetY {
weakSelf.behaviorEndAnimation()
}
}else { // webView 没滚动到底部,webView 滚动
weakSelf.setScrollOffsetY(dValue: dValue, isWebView: true)
}
}else { // tableView offsetY 减少或不变
if weakSelf.tableViewOldOffsetY <= weakSelf.tableViewBeganOffsetY { // tableView 滚到顶部,webView滚动
weakSelf.setScrollOffsetY(dValue: dValue, isWebView: true)
// webView 滚动到顶部时,结束惯性
if weakSelf.webViewOldOffsetY <= weakSelf.webViewBeganOffsetY {
weakSelf.behaviorEndAnimation()
}
}else { // tableView 没滚到顶部, tableView 滚动
weakSelf.setScrollOffsetY(dValue: dValue, isWebView: false)
}
}
// 保存旧的值
weakSelf.oldItemCenterY = weakSelf.item.center.y
}
// 启动物理动画
animator.addBehavior(behavior)
}else { // 不需要惯性
// 关闭物理动画
notBehaviorAnimation()
}
}
/// 关闭惯性动画
func notBehaviorAnimation() {
tableViewUserInteractionEnabledState(true)
// 关闭惯性动画的同时,将 offsetY 归位
if tableViewOldOffsetY > tableViewMaxOffsetY {
tableView.setContentOffset(.init(x: 0, y: tableViewMaxOffsetY), animated: true)
tableViewOldOffsetY = tableViewMaxOffsetY
}
}
/// 惯性动画即将结束
func behaviorWillEndAnimation() {
tableViewUserInteractionEnabledState(true)
}
/// 结束惯性动画
func behaviorEndAnimation() {
animator.removeAllBehaviors()
notBehaviorAnimation()
}
/// 设置 tableView 是否允许交互
func tableViewUserInteractionEnabledState(_ state: Bool) {
tableView.isUserInteractionEnabled = state
}
然后我们就实现了惯性的滚动。
当然还有很多细节,比如滚动进度条显示,点击事件响应,什么时候取消惯性动画,什么时候开放 TableView
响应就不一一细说了,可以直接看源码。
缺点也是有的,就是滚动起来没有系统的那么丝滑,总感觉有点抖动不清楚为什么。以及 MJRefresh
的 BackNormalFoot 不能用,可以用 AutoNormalFotter。下拉刷新功能没有写,因为我们的项目不需要这个功能暂时,有需要的可以自己补一下逻辑。
自己也已经封装过了,几句代码就可以添加在现有的项目中:
class ViewController2: UIViewController {
// 第一句代码在这
var gestureControl: YTWebScrollGestureControl?
// 第二句代码
lazy var contentView = YTReferenceView()
lazy var tableView = UITableView()
lazy var webView = WKWebView()
// ======================
override func viewDidLoad() {
super.viewDidLoad()
// 第三句代码,tableView 需要是 YTReferenceView 的子 View
self.view.addSubview(contentView)
self.contentView.addSubview(tableView)
// contentView
contentView.frame = .init(x: 0, y: 64, width: view.bounds.width, height: view.bounds.height - 64)
// tableView
tableView.frame = .init(x: 0, y: 0, width: contentView.bounds.width, height: contentView.bounds.height)
tableView.tableHeaderView = webView
// BackNormalFoot 不能用,可以用 AutoNormalFotter 或 AutoGifFooter
tableView.mj_footer = MJRefreshAutoGifFooter(refreshingBlock: {
DispatchQueue.main.asyncAfter(deadline: .now() + 2) {
self.tableView.mj_footer?.endRefreshing()
print("上拉加载")
}
})
// webView
webView.load(URLRequest(url: URL(string: "找一个URL")!))
webView.frame = .init(x: 0, y: 0, width: view.bounds.width, height: view.bounds.height - 64)
// 上边都是示例代码,第四句代码在这 gestureControl
gestureControl = .init(referenceView: contentView, tableView: tableView, webView: webView)
// 这个代理是为了监听是否正在滚动的,因为联动会导致自带的 didscroll offsety 不好计算
gestureControl?.delegate = self
}
}
不过建议如果有这种需要,要么就全 Web 写,要么就全原生写吧。这样拼在一起,保不齐会有什么 bug,而且要有需求改变的时候,WebView
可控性较小。
其他的就自己看看源码吧!有问题可以告诉我。
觉得有用就点个赞支持一下,嘻