Skip to the content.

旧 JX 分段分页场景迁移到 PTools

本教程把 YDShipOrderControl.swift 中的“固定分段栏 + 多个订单列表 + 懒加载 + 动态角标 + 外层刷新”迁移到 PTools。PTools 不再暴露第三方类型,页面与分段都使用稳定 ID。

1. 组件对应关系

旧场景 PTools 入口
JXSegmentedView PTSegmentedView
JXSegmentedTitleDataSource [PTSegmentItem] + PTSegmentStyle
JXSegmentedIndicatorLineView PTLineIndicator
JXPagingView PTPagingView
JXPagingView.listContainerView PTPagingView.pageContainer / listContainerView
initListAtIndex PTPageDescriptor 的 viewController 工厂
defaultSelectedIndex pageContainer.select(index:) 或稳定 ID
listViewDidScrollCallback PTScrollablePage.pageScrollView,或由 PTools 自动查找页面内 ScrollView
mainTableView.refreshControl pagingView.refreshControl
listContainerView.scrollView.isScrollEnabled = false pagingView.isListHorizontalScrollEnabled = false
validListDict.keys pagingView.pageContainer.loadedPageIDs
validListDict[index] loadedPage(for:) 或 loadedViewController(for:)
当前已加载 Controller pagingView.pageContainer.currentViewController
遍历已加载 Controller pagingView.pageContainer.loadedViewControllers(of:)

2. 基础初始化

@MainActor
final class YDShipOrderControl: PTBaseViewController {
    private let segmentedView = PTSegmentedView(frame: .zero)
    private let pagingView = PTPagingView()
    private var pagingCoordinator: PTSegmentedPagingCoordinator?
    private let refreshControl = UIRefreshControl()

    override func viewDidLoad() {
        super.viewDidLoad()

        pagingView.hostViewController = self
        pagingView.isListHorizontalScrollEnabled = false
        pagingView.refreshControl = refreshControl
        pagingView.pageContainer.cachePolicy = .adjacent(radius: 1)

        view.addSubview(pagingView)
        pagingView.translatesAutoresizingMaskIntoConstraints = false
        NSLayoutConstraint.activate([
            pagingView.topAnchor.constraint(equalTo: view.topAnchor),
            pagingView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
            pagingView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
            pagingView.bottomAnchor.constraint(equalTo: view.bottomAnchor)
        ])

        pagingView.setPinnedHeader(PTPagingPinnedHeader(height: GobalSegmentHeight) {
            self.segmentedView
        })

        refreshControl.addAction(UIAction { [weak self] _ in
            self?.reloadAllOrderLists()
        }, for: .valueChanged)

        pagingCoordinator = PTSegmentedPagingCoordinator(
            segmentedView: segmentedView,
            pageContainer: pagingView.pageContainer
        )

        reloadSegmentAndPages()
    }
}

没有头图时不需要伪造 Header,setPinnedHeader 会在 headerHeight == 0 时直接显示分段栏;有头图时再调用 setHeader,固定分段栏会随外层滚动折叠到安全区下方。

3. 用稳定 ID 生成分段和页面

不要使用标题、带角标的显示文本或数组索引作为长期 ID。角标变化只更新 content,不能让 Diffable 认为页面换了。

private func reloadSegmentAndPages() {
    let models = orderModels

    let items = models.map { model in
        PTSegmentItem.title(
            id: model.id,
            model.name,
            badgeDescriptor: model.badge > 0
                ? PTSegmentBadgeDescriptor(content: .number(model.badge))
                : nil
        )
    }

    let pages = models.map { model in
        PTPageDescriptor(id: model.id) { [weak self] in
            let controller = YDShipOrderListViewController(
                segmentModel: model,
                listType: self?.orderType ?? .CN
            )
            controller.reloadMainListCallback = { [weak self] in
                self?.reloadAllOrderLists()
            }
            return controller
        }
    }

    pagingCoordinator?.apply(items: items, pages: pages, animated: false)
}

PTSegmentedPagingCoordinator.apply 会优先保留当前选中的稳定 ID。订单数量刷新、语言刷新和网络重载不会无故跳回第一个列表。

4. 导航栏和选中回调

旧代码把 currentSegIndex 和导航返回手势逻辑放在 didSelectedItemAt 中。新代码可以使用类型化事件:

segmentedView.onItemSelected = { [weak self] index, origin in
    guard let self else { return }
    self.currentSegmentIndex = index

    if origin == .tap {
        self.navigationController?.interactivePopGestureRecognizer?.isEnabled = index == 0
    }
}

segmentedView.onScrolling = { [weak self] _, _, _ in
    guard let self else { return }
    PTNavigationBarManager.shared.restoreIfNeeded(for: self)
}

如果只关心最终选中项,使用 onSelectionChanged;如果需要像旧代理一样区分点击、横向滑动和重复点击,使用 onItemSelected 与 onReselected。

5. 页面滚动、刷新和返回顶部

页面是 UIViewController 时直接使用 PTPageDescriptor(viewController:)。PTools 会维护 child containment、懒加载和页面生命周期,并递归寻找页面内部的 UIScrollView。复杂页面可以显式实现 PTScrollablePage,返回真正用于列表滚动的 ScrollView。

pagingView.scrollCurrentPageToTop(animated: true)

// English: End the outer refresh after the host finishes reloading the header or pages.
// Español: Finaliza el refresco exterior cuando el host termina de recargar la cabecera o las páginas.
// 中文:宿主完成 Header 或页面刷新后结束外层刷新。
pagingView.refreshControl?.endRefreshing()

// English: Access the current list for per-page refresh, pagination or custom coordination.
// Español: Accede a la lista actual para refresco por página, paginación o coordinación personalizada.
// 中文:获取当前列表,用于单页刷新、分页加载或自定义协调。
let currentList = pagingView.pageContainer.currentPageScrollView

6.1 查询已加载页面(5.57.3)

PTPageContainer 现在提供只读查询 API,业务不需要再访问旧的 validListDict。所有查询都使用稳定 ID,并且不会创建尚未加载的页面、改变选中状态或触发生命周期回调。

let currentListController = pagingView.pageContainer.currentViewController(
    as: YDShipOrderListViewController.self
)

let loadedControllers = pagingView.pageContainer.loadedViewControllers(
    of: YDShipOrderListViewController.self
)

for controller in loadedControllers {
    controller.reloadMainList(showHud: false)
}

loadedPageIDs 只返回当前缓存真正持有的页面,并按照 descriptors 顺序返回。discardOffscreen、adjacent、limit 和 keepAllLoaded 的结果完全遵循容器现有 cachePolicy;页面卸载后,所有对应查询都会返回 nil 或不再包含该 ID。

6.2 订单状态分段回归

订单状态页通常同时包含短中文标题、较长本地化标题和动态数量角标。更新角标时保持订单状态 的稳定 ID 不变,只替换 PTSegmentItem 的 content 或 badgeDescriptor:

let items = orderModels.map { model in
    PTSegmentItem.title(
        id: model.id,
        model.localizedName,
        badgeDescriptor: model.pendingCount > 0
            ? PTSegmentBadgeDescriptor(content: .number(model.pendingCount))
            : nil
    )
}
coordinator.apply(items: items, pages: pages, animated: false)

PTSegmentedView 会按 normal/selected 字体、Core Badge 尺寸和 selectedScale 重新测量宽度。用户 拖动标题条时只浏览分段并更新 Indicator 的 viewport 位置,不会改变当前订单页;订单页面的 左右滑动才会通过 Coordinator 产生 Indicator transition。建议在中文、英文、西班牙语、RTL、 Dynamic Type 和 Reduce Motion 环境分别验证首项、末项、100 个状态项以及快速点击/快速滑页。

当需要 JX 的视觉效果时,在 PTSegmentStyle 开启 isTitleColorGradientEnabled、 isTitleZoomEnabled、isSelectedAnimable 和 isItemSpacingAverageEnabled;平均间距只扩大 真实 Item 之间的 Gap,不会把不同宽度的订单状态强制变成等宽。角标删除通过 onBadgeRemoved 回调后由业务重新生成同一批稳定 ID。

外层刷新和页面自己的刷新状态应分开管理;不要把同一个 UIRefreshControl 同时安装到外层和每个内层列表。页面需要独立下拉刷新时,使用页面自己的刷新控件,并把外层策略设置为 .perPage 或由宿主自行协调。

6. 手势协作

不要替换 UIScrollView.panGestureRecognizer 的系统 delegate。宿主如果有自定义手势 delegate,在已有代理回调中转发:

func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer,
                       shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool {
    pagingView.shouldRecognizeSimultaneously(
        gestureRecognizer,
        otherGestureRecognizer
    )
}

PTools 默认允许横向分页、垂直列表和导航返回之间进行必要的同时识别,并且所有嵌套偏移修正都考虑 adjustedContentInset。这避免了旧代码中直接操作内部列表代理后丢失业务回调的问题。

7. 动态数据和页面回收注意事项