全部文章

一个页面、两个 Table View、三种加载任务

从 Banner 与内容列表的滚动交接出发,明确手势归属、惯性交接和刷新、运营位、分页之间的优先级。

看起来是一张页面,实际有两套滚动

页面顶部是来自运营后台的 Banner,下面是内容列表。设计希望它像一个连续表面:先把 Banner 向上收起,再自然地滚动内容。

实现上,外层 table view 的上半部分放 Banner,下半部分是一个与视口等高的 cell;内层内容 table view 放在这个 cell 中,拥有自己的数据、行和分页。

嵌套列表在滚动交接前后的状态到达 Banner 边界之前,外层滚动、内层固定;到达边界之后,归属反转。

Banner 高度决定交接位置,下文称为 ceiling。两个列表都识别竖直 pan,于是问题分成两部分:谁可以响应滚动,以及当前手势允许触发哪一种加载。后者依赖前者,不能独立处理。

先解决手势归属

默认竞争为什么不符合设计

UIKit 默认倾向于让一个识别器获胜。手指落在内容区时,Banner 还没收起,内层就开始滚动;落在 Banner 时,外层可能继续把整个内容 cell 推走,里面的行却不动。

需要的规则其实很严格:先收起 Banner,再滚内容;交接之后,外层不得继续移动。

慢速轻扫还会暴露另一类问题:手指离开后,惯性属于原来的 scroll view。它到达自己的边缘,剩余速度可能被回弹消耗掉,而不会自然交给另一个列表。快速滑动时不明显,慢滑时却会出现晃动或停顿。

同时识别,再决定谁能移动

让外层仅与指定的内层 pan 同时识别:

final class PageContainerTableView: UITableView, UIGestureRecognizerDelegate {
    func gestureRecognizer(
        _ gestureRecognizer: UIGestureRecognizer,
        shouldRecognizeSimultaneouslyWith other: UIGestureRecognizer
    ) -> Bool {
        // Cooperate only with our own embedded content list,
        // not with sliders, swipe-to-delete, or anything else inside cells.
        other.view is ContentTableView
    }
}

两个列表都能观察手势,再由协调器每帧固定当前不应该移动的一方。交接边界来自 Banner 高度:

var ceilingOffset: CGFloat {
    bannerHeight   // derived from the slot data; more on this in Part Two
}

不变量是:外层只在 [0, ceiling] 内滚动;内层只有在外层固定于 ceiling 时才能滚动。

extension HomePageViewController: UIScrollViewDelegate {
    func scrollViewDidScroll(_ scrollView: UIScrollView) {
        if scrollView === containerTableView {
            if scrollState.contentOwnsScrolling {
                // Past the banner: ignore the container, hold it at the ceiling.
                containerTableView.contentOffset.y = ceilingOffset
            } else if containerTableView.contentOffset.y >= ceilingOffset {
                // The banner just finished collapsing; hand over.
                containerTableView.contentOffset.y = ceilingOffset
                scrollState.contentOwnsScrolling = true
            }
        } else if scrollView === contentTableView {
            if !scrollState.contentOwnsScrolling {
                // Banner still visible: ignore the content list, keep it at row zero.
                contentTableView.contentOffset = .zero
            } else if contentTableView.contentOffset.y <= 0 {
                // Scrolling up: the content returned to its top; hand back.
                contentTableView.contentOffset = .zero
                scrollState.contentOwnsScrolling = false
            }
        }
    }
}

向上浏览时,先收起 Banner,再移动内容。返回顶部时,先让内容回到首行,再移动外层,重新露出 Banner。

示例假设 adjusted content inset 为零。生产代码需要从 -adjustedContentInset.top 推导逻辑顶部,不能直接把 .zero 当作所有 scroll view 的起点。协调器在 scrollViewDidScroll 内直接设置偏移,不使用额外动画,状态只在边界穿越时切换。

惯性交接要单独验证

同时识别解决的是手势竞争,不保证共享同一套物理减速过程。两个 scroll view 仍有各自的内容边界、inset、回弹和减速状态。持续固定 contentOffset 也可能改变减速行为。

期望的惯性交接轨迹交接时希望保持可见速度连续,但两个列表并没有系统保证的共享物理模拟。

因此,协调器需要在目标设备和系统版本上验证。若固定偏移破坏了惯性,更可靠的选择是捕获离开一方的速度,用一个显式 animator 负责边界两侧运动;或重新设计为只有一个外层滚动表面。

两边使用一致的 decelerationRate,可以避免阻尼明显改变;顶部回弹归外层,供整页下拉刷新使用;内层保留底部回弹,用于接近底部的分页体验。但这些设置仍不能替代设备上的连续性验证。

再解决三种加载的竞争

页面同时存在三类任务:进入页面时加载运营位;下拉刷新时重新加载运营位和第一页内容;接近底部时追加下一页。

常见竞争有三种:刷新完成后,旧分页结果又追加进来;底部滚动回调逐帧触发,重复请求同一页;晚到的运营位改变 Banner 高度,让交接边界在用户滚动途中移动。

优先级由影响范围决定

任务修改范围规则
整页刷新替换运营位与内容,重置偏移优先级最高,取消旧任务并使旧结果失效
运营位更新改变 Banner 高度和 ceiling等待安全时机应用,刷新时合并处理
底部分页追加内容同一时刻只允许一个,刷新期间拒绝触发

刷新开始后,其他触发器暂时关闭。分页重复触发直接忽略,不排队,因为进行中的请求已经在获取所需页面。运营位响应可以先接收,但高度变化要等外层靠近顶部,或整页刷新重置时再应用。

把这些规则集中到一个仲裁器:

enum PageLoad: Hashable {
    case refresh
    case slots
    case nextPage
}

struct LoadToken: Equatable {
    let load: PageLoad
    let generation: Int
}

enum LoadDecision: Equatable {
    case start(LoadToken)
    case startCancellingCurrent(LoadToken)
    case reject
}

final class LoadArbiter {
    private var nextGeneration = 0
    private var active: [PageLoad: LoadToken] = [:]

    func request(_ load: PageLoad) -> LoadDecision {
        switch load {
        case .refresh:
            active.removeAll()
            let token = makeToken(for: .refresh)
            active[.refresh] = token
            return .startCancellingCurrent(token)

        case .slots:
            guard active[.refresh] == nil, active[.slots] == nil else {
                return .reject
            }
            let token = makeToken(for: .slots)
            active[.slots] = token
            return .start(token)

        case .nextPage:
            guard active[.refresh] == nil, active[.nextPage] == nil else {
                return .reject
            }
            let token = makeToken(for: .nextPage)
            active[.nextPage] = token
            return .start(token)
        }
    }

    func complete(_ token: LoadToken) -> Bool {
        guard active[token.load] == token else { return false }
        active[token.load] = nil
        return true
    }

    private func makeToken(for load: PageLoad) -> LoadToken {
        nextGeneration += 1
        return LoadToken(load: load, generation: nextGeneration)
    }
}

完成回调携带 token,只有 complete(_:) 接受当前 token 时才应用数据。刷新先清除旧 token,再发起新任务。这样即使取消后的旧请求仍然回调,也不能污染刚刷新的列表。

由手势归属控制加载入口

整页刷新只在外层拥有滚动且位于顶部时触发。内层向下拖动只是返回内容顶部,不应该意外刷新整个页面。

底部分页只在内层拥有滚动时触发。交接前,内层被固定,其回调可能来自协调器写入偏移,不代表用户意图。

手势归属如何控制刷新与分页外层归属允许刷新,内层归属允许分页;两个入口最终经过同一个任务仲裁器。

大部分滚动回调应该得到“无需加载”的结果。手势系统使用运营数据推导出的边界,数据系统又使用手势归属控制触发。两者需要共享明确状态,但不能互相随意改写内部实现。

不打开页面,也能测试策略

仲裁器是纯状态机,可以通过表驱动测试验证优先级:

func testRefreshCancelsInFlightNextPage() {
    let arbiter = LoadArbiter()
    guard case let .start(pageToken) = arbiter.request(.nextPage) else {
        return XCTFail("Expected pagination to start")
    }

    guard case .startCancellingCurrent = arbiter.request(.refresh) else {
        return XCTFail("Expected refresh to supersede pagination")
    }
    XCTAssertFalse(arbiter.complete(pageToken))
}

func testRepeatedBottomHitIsAbsorbed() {
    let arbiter = LoadArbiter()
    _ = arbiter.request(.nextPage)

    // Bounce frames re-evaluate the trigger; only one request may fly.
    XCTAssertEqual(arbiter.request(.nextPage), .reject)
    XCTAssertEqual(arbiter.request(.nextPage), .reject)
}

func testSlotsAreFoldedIntoRefresh() {
    let arbiter = LoadArbiter()
    _ = arbiter.request(.refresh)

    XCTAssertEqual(arbiter.request(.slots), .reject)
}

滚动协调器也可以用输入偏移、当前归属,验证下一归属和固定值。真正的惯性连续性则留给设备测试,避免把状态测试误当作物理行为测试。

这套方案的取舍

协调器运行频繁,应避免每帧分配对象,保持分支简单。同时识别的范围必须收窄,不能对所有手势无条件返回 true,否则会破坏水平滑动和 cell 内控件。

加载选择“丢弃重复触发”而不是“排队”,失败后由新的明确操作重试。延后 Banner 高度更新,则是用少量新鲜度换取页面不自行跳动。

真正起作用的不是某个 delegate 技巧,而是两份明确约定:当前谁拥有可见滚动,以及哪类任务有权使旧状态失效。先定义这些,再连接回调,嵌套列表才不会成为互相竞争的两套系统。