iOS WebView自适应高度(ScrollView嵌套滚动)

  本方案通过重写手势滚动代码,解决了 ScrollViewTableView 的嵌套滚动问题。同时通过障眼法的方案实现了 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 监听其内部 ScrollViewContentSize 改变。每次改变就更新一下 WebView 的高度。

  但这样还会产生另一个问题,由于我们修改的是 WebView 本身 Frame 的高度,当新页面的 ContentSize 高度低于 WebView 的高度时,KVO 就不会更新 ContentSize 。也就是说,当你进入一个特别长的网页后,再跳转到短一点的网页会出现底部留有大片空白的问题。

  这个问题是非常影响用户体验的,所以我开始调研解决该问题的方法。

二、方案调研

  先说最终的方案理论,首先将 WebView 的高度设置为手机屏幕的高度(有导航栏的去除导航栏高度),然后将 WebView 设置为 TableView 的子 View,或者设置为 headerCell 都可以。然后我们自己控制 WebViewTableView 的滚动。因为进入页面后优先显示网页,当网页滚动到底部时列表才会滚动,在视觉上就会认为网页和列表是连在一起的,这样就达成了我们的目的。

  所以我们需要专门去写一层手势控制,用手势去控制 WebViewTableView 的联动。

  类似于分页的手势嵌套,不过不一样的是,分页的手势嵌套是 TableViewScrollView 上方,虽然表面上是 TableView 在滚动,实际上两者的 frame 都没有发生变化。而 TableView 嵌套 WebView 会出现 WebView 滚动出屏幕的情况,这时候原本的手势就不会作用在 WebView 上,就导致当我们 TableView 滚动到顶部时,由于手势穿透没有作用于 WebView 从而出现手势中断的问题。

方案调研流程,不想看的可以跳过。

  首先我发现将 WebViewSize 设置为 Zero 后再刷新一下 WebView,就可以重新监听到 ContentSize 的改变。但会造成一次屏幕的闪烁,不过影响不大,用户在视觉效果上会认为是网页的加载。我只需要知道页面什么时候跳转或者改变,就可以控制刷新的时机,于是我把调研的重点放在了监听页面的变化。


  我认为,既然网页是在点击某个按钮后进行的跳转,那在网页加载的时候刷新一下高度不就可以了。然后我对 estimatedProgress 进行了监听,在页面加载完成后通过上面的方法刷新一下 WebView

  经过实验,这种方式确实可以实现高度的修改。但又出现了一个新的问题,大部分的页面都是通过 URL 的跳转完成的,监听页面加载确实没有问题。但还有一种是在页面内进行的分页,页面内部进行上一页下一页的跳转时,依然会造成高度变高后无法缩小的问题,并且由于并没有发生 URL 跳转我们也无法监听到。

  所以这种方案还有些缺陷。


  于是我想,如果网页在点击按钮跳转时通过 js 回调给我们呢,经过询问发现我们的所有文章网页都是通过第三方工具生成的,也就是说没办法在里边增加 js 回调,那么这个方案也不大行。


  既然网页不能回调给我,那我能不能对网页中的按钮添加监听呢,当网页加载完毕后对所有的按钮增加监听,当用户点击按钮时进行 js 回调,收到回调后我再刷新页面。有了这个想法后我去查找相关的代码,找到了注册 js 标签监听的方法,于是我添加对 <button> 标签的监听。

  当我满心欢喜以为要成功时又出现了新问题,有些按钮监听不到。我打开网页调试工具发现我想的太简单了,网页中用各种标签做跳转 <a>, <div>, <img>等,我不能监听所有标签的点击事件,那样点击一个标签就会闪一下,严重影响用户体验也会出现其他奇怪的bug。


  之后又对 WebView 进行遍历查看层级,私有属性之类的,总之也走了不少弯路,最后想到了这种手势嵌套的方案。

三、方案实现

  既然要自己写手势滚动,就需要先禁用 WebViewTableView 的滚动功能。然后创建一个新的 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
    }
}

  这样我们就实现了 WebViewTableView 的联动滚动。

  但是还没结束,现在只是实现了滚动功能,为了和原生滚动相似,我们还需要实现惯性滚动功能。惯性滚动功能使用的是 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 响应就不一一细说了,可以直接看源码。

  缺点也是有的,就是滚动起来没有系统的那么丝滑,总感觉有点抖动不清楚为什么。以及 MJRefreshBackNormalFoot 不能用,可以用 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 可控性较小。

  其他的就自己看看源码吧!有问题可以告诉我。

觉得有用就点个赞支持一下,嘻

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 212,080评论 6 493
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 90,422评论 3 385
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 157,630评论 0 348
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 56,554评论 1 284
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 65,662评论 6 386
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,856评论 1 290
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,014评论 3 408
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,752评论 0 268
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,212评论 1 303
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,541评论 2 327
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,687评论 1 341
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,347评论 4 331
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,973评论 3 315
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,777评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,006评论 1 266
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 46,406评论 2 360
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 43,576评论 2 349

推荐阅读更多精彩内容