UIScrollView 嵌套滚动 iOS · 2026-04-14

iOS 嵌套滚动(Nested Scroll)实现原理

一、页面结构

┌─────────────────────────────────┐ │ outerScrollView(外层纵向滚动) │ │ ┌───────────────────────────┐ │ │ │ HeaderView(头部区域) │ │ │ │ 背景图 / 头像 / 统计 / 关注 │ │ │ └───────────────────────────┘ │ │ ┌───────────────────────────┐ │ │ │ TabBar(动态/回答/文章) │ │ ← 吸顶位置 │ └───────────────────────────┘ │ │ ┌───────────────────────────┐ │ │ │ horizontalScrollView │ │ │ │ ┌─────┐┌─────┐┌─────┐ │ │ │ │ │ TV0 ││ TV1 ││ TV2 │ │ │ ← 3 个内层 TableView │ │ └─────┘└─────┘└─────┘ │ │ │ └───────────────────────────┘ │ └─────────────────────────────────┘

二、核心难题

外层 ScrollView 和内层 TableView 都是纵向滚动,默认情况下 iOS 只会让其中一个响应手势。我们需要实现:

三、解决方案:手势同时识别 + 实时 Clamp

3.1 手势同时识别

// 子类化外层 ScrollView
@interface NestedOuterScrollView : UIScrollView <UIGestureRecognizerDelegate>
@end

@implementation NestedOuterScrollView

- (BOOL)gestureRecognizer:(UIGestureRecognizer *)gestureRecognizer
shouldRecognizeSimultaneouslyWithGestureRecognizer:(UIGestureRecognizer *)otherGestureRecognizer {
    return YES;
}

@end
为什么要子类化?
UIScrollView 要求 panGestureRecognizer.delegate 必须是自身,直接设置为其他对象会 crash。通过子类化,UIScrollView 自身就是 delegate,重写的方法自然生效。返回 YES 后,一次手指拖动会同时驱动外层和内层两个 ScrollView。

3.2 实时 Clamp(scrollViewDidScroll: 核心逻辑)

两个 ScrollView 同时被手势驱动,但每一帧只有一个应该真正滚动,另一个应该被"钉住"。

- (void)scrollViewDidScroll:(UIScrollView *)scrollView {

    // ===== 外层 ScrollView =====
    if (scrollView == outerScrollView) {
        if (offsetY >= stickyOffset) {
            // 外层到达吸顶 → 锁死外层,允许内层
            scrollView.contentOffset = CGPointMake(0, stickyOffset);
            innerCanScroll = YES;
        } else {
            if (innerTV.contentOffset.y > 0) {
                // 【关键】内层还有内容没滚到顶
                // → 外层也锁死在吸顶,优先让内层先滚
                scrollView.contentOffset = CGPointMake(0, stickyOffset);
            } else {
                // 内层已在顶部 → 外层自由滚动
                innerCanScroll = NO;
            }
        }
    }

    // ===== 内层 TableView =====
    if (scrollView == innerTableView) {
        if (!innerCanScroll) {
            // 外层还没到吸顶,钉住内层
            scrollView.contentOffset = CGPointZero;
        } else if (scrollView.contentOffset.y <= 0) {
            // 内层到顶了,交还给外层
            innerCanScroll = NO;
            scrollView.contentOffset = CGPointZero;
        }
    }
}

四、向上滑(看更多内容)的流程

手指向上拖动 ↑ │ ├─ 阶段1: 外层 offset < stickyOffset, innerCanScroll = NO │ 外层自由滚动(Header 收起),内层被 clamp 在 0 │ ├─ 过渡点: 外层 offset 到达 stickyOffset(Tab 吸顶) │ 外层被 clamp 在 stickyOffset,innerCanScroll = YES │ └─ 阶段2: innerCanScroll = YES 外层锁定,内层自由滚动(浏览列表内容)

同一个手势,无需松手,从外层滚动无缝过渡到内层滚动。

五、向下滑(回到顶部)的流程

手指向下拖动 ↓(此时外层在吸顶位置,内层有内容偏移) │ ├─ 阶段1: innerTV.contentOffset.y > 0 │ 外层被 clamp 在 stickyOffset(不动) │ 内层自由向下滚动(列表内容回到顶部) │ ├─ 过渡点: innerTV.contentOffset.y 到达 0 │ innerCanScroll = NO,内层被 clamp 在 0 │ └─ 阶段2: innerCanScroll = NO, innerTV.contentOffset.y == 0 外层自由向下滚动(Header 开始露出)

同一个手势,无需松手,从内层滚动无缝过渡到外层滚动。

六、关键设计要点总结

要点说明
手势同时识别通过子类化 UIScrollView 实现,不能直接改 panGestureRecognizer.delegate
只用一个标志位innerCanScroll,不需要 outerCanScroll
外层向下判断外层 offset < stickyOffset 时,先看内层是否到顶,没到顶则外层也锁住
clamp 而非阻止不是阻止事件传递,而是让两个都收到事件,实时 clamp 不该动的那个
无缝过渡同一个手势中,在一帧内完成角色切换,用户无感知

七、踩坑记录

Crash: UIScrollView's built-in pan gesture recognizer must have its scroll view as its delegate
不能直接设置 outerScrollView.panGestureRecognizer.delegate = viewController,必须通过子类化 UIScrollView,在子类内重写手势代理方法。
向下滑时内层内容直接归零
原因:外层 else 分支只看自己的 offset < stickyOffset 就把 innerCanScroll = NO
修复:增加 innerTV.contentOffset.y > 0 判断,内层没到顶时外层也锁住。
互斥标志位方案(outerCanScroll + innerCanScroll)的问题
在同一手势中无法实现无缝过渡,必须松手后重新滑。去掉 outerCanScroll,只用 innerCanScroll + 实时 clamp 即可。