引言
ReactNative的很多第三方模块都不怎么维护了,现在质量有保障的是Expo官方提供的一系列库,比如expo-image可以替换很久没维护的react-native-fast-image,所以感觉RN开发的趋势就是Expo框架了。
我以前还挺担心Expo框架的一些第三方原生模块的集成和打包,其实当捋清楚它的功能和文档以后 还挺好用的,不用担心打包和第三方库的问题。
集成第三方原生库(比如支付宝、微信、友盟、极光推送)等,打包可以prebuild出ios和安卓目录,然后用Xcode或Android打包即可。
第三方库的集成原生代码可以使用AI生成。
1、生成 Expo(SDK54)项目
查看npx create-expo-app@latest --help命令,初始化Expo项目如下,选一个就好了:
# 使用npm安装
npx create-expo-app 项目名
# 使用yarn安装
yarn create expo-app 项目名
# 使用pnpm安装
pnpm create expo-app 项目名
# 使用bun安装
bun create expo-app 项目名
初始化好以后,可以删除不要的web文件,比如截图所示:
2、集成 PullRefresh 功能
这里演示 Ios使用MJRefresh库,Android使用SmartRefreshLayout库。
首先创建一个Expo的原生模块,比如pull-refresh,跟着下面命令去创建就好。
npx create-expo-module@latest --local
2.1 ios集成 MJRefresh
修改modules/pull-refresh/ios/PullRefresh.podspec文件,加入MJRefresh
接着expo prebuild出安卓和ios目录
# prebuild出安卓和ios目录
npx expo prebuild --clean
2.1.1 PullRefreshModule.swift
编写ios原生代码,AI开发modules/pull-refresh/ios/PullRefreshModule.swift
import ExpoModulesCore
import MJRefresh
public class PullRefreshModule: Module {
public func definition() -> ModuleDefinition {
Name("PullRefresh")
// 定义视图组件
View(PullRefreshView.self) {
// 事件定义
Events("onRefresh", "onLoadMore")
// Props定义 - 功能控制
Prop("refreshEnabled") { (view: PullRefreshView, enabled: Bool) in
view.setRefreshEnabled(enabled)
}
Prop("loadMoreEnabled") { (view: PullRefreshView, enabled: Bool) in
view.setLoadMoreEnabled(enabled)
}
// Props定义 - 文字设置
Prop("refreshingText") { (view: PullRefreshView, text: String) in
view.setRefreshingText(text)
}
Prop("loadMoreText") { (view: PullRefreshView, text: String) in
view.setLoadMoreText(text)
}
// Props定义 - 状态控制
Prop("refreshingKey") { (view: PullRefreshView, key: String) in
view.setRefreshingKey(key)
}
Prop("loadingKey") { (view: PullRefreshView, key: String) in
view.setLoadingKey(key)
}
Prop("noMoreData") { (view: PullRefreshView, flag: Bool) in
view.setNoMoreDataFlag(flag)
}
// 注意:不需要暴露 endRefresh 等方法给 JS
// 已通过 Props 的 key 机制(refreshingKey、loadingKey)来控制刷新状态
// 这种方式更符合 React 的声明式编程理念,且避免了复杂的方法调用
}
}
}
2.1.2 PullRefreshView.swift
编写ios原生代码,AI开发modules/pull-refresh/ios/PullRefreshView.swift
import ExpoModulesCore
import UIKit
import MJRefresh
// PullRefresh视图组件,集成MJRefresh功能
class PullRefreshView: ExpoView {
// 事件分发器
let onRefresh = EventDispatcher()
let onLoadMore = EventDispatcher()
// 刷新状态标记
private var isRefreshing = false
private var isLoadingMore = false
// 用于追踪状态变化的key
private var currentRefreshingKey: String = ""
private var currentLoadingKey: String = ""
private var currentNoMoreData: Bool = false
// 存储配置,等待找到ScrollView后应用
private var refreshEnabled = true
private var loadMoreEnabled = true
private var refreshingText = "正在刷新..."
private var loadMoreText = "加载更多..."
private var idlePullingText = "下拉刷新"
private var releasePullingText = "释放立即刷新"
private var idleLoadingText = "点击或上拉加载更多"
private var releaseLoadingText = "释放立即加载"
private var noMoreDataText = "没有更多数据了"
// 找到的ScrollView引用
private weak var targetScrollView: UIScrollView?
// 是否已经设置过刷新控件
private var hasSetupRefreshControls = false
// 同步队列,保证线程安全
private let syncQueue = DispatchQueue(label: "com.pullrefresh.sync")
required init(appContext: AppContext? = nil) {
super.init(appContext: appContext)
// 不创建自己的ScrollView,等待子视图添加
}
// 递归查找第一个UIScrollView(优化:添加深度限制)
private func findScrollView(in view: UIView, depth: Int = 0, maxDepth: Int = 10) -> UIScrollView? {
// 防止过深的递归
guard depth < maxDepth else { return nil }
// 优先检查当前视图
if let scrollView = view as? UIScrollView {
return scrollView
}
// 广度优先搜索,先检查直接子视图
for subview in view.subviews {
if let scrollView = subview as? UIScrollView {
return scrollView
}
}
// 如果直接子视图中没有,再递归搜索
for subview in view.subviews {
if let scrollView = findScrollView(in: subview, depth: depth + 1, maxDepth: maxDepth) {
return scrollView
}
}
return nil
}
// 在找到的ScrollView上设置MJRefresh
private func setupRefreshControls(on scrollView: UIScrollView) {
// 防止重复设置
guard !hasSetupRefreshControls else {
print("⚠️ PullRefresh: 刷新控件已设置,跳过重复设置")
return
}
// 移除可能存在的旧控件
scrollView.mj_header = nil
scrollView.mj_footer = nil
// 设置下拉刷新
let header = MJRefreshNormalHeader { [weak self] in
guard let self = self else { return }
DispatchQueue.main.async {
self.syncQueue.async {
self.isRefreshing = true
}
self.onRefresh([:])
}
}
header.lastUpdatedTimeLabel?.isHidden = true
header.stateLabel?.textColor = .gray
header.stateLabel?.font = .systemFont(ofSize: 14)
// 设置不同状态的文字(使用配置的文字)
header.setTitle(idlePullingText, for: .idle) // 默认状态
header.setTitle(releasePullingText, for: .pulling) // 下拉中(超过触发距离)
header.setTitle(refreshingText, for: .refreshing) // 正在刷新
header.setTitle("", for: .willRefresh) // 即将刷新
header.setTitle("", for: .noMoreData) // 没有更多数据
header.isHidden = !refreshEnabled
scrollView.mj_header = header
// 设置上拉加载
let footer = MJRefreshAutoNormalFooter { [weak self] in
guard let self = self else { return }
DispatchQueue.main.async {
self.syncQueue.async {
self.isLoadingMore = true
}
self.onLoadMore([:])
}
}
footer.stateLabel?.textColor = .gray
footer.stateLabel?.font = .systemFont(ofSize: 14)
// 设置不同状态的文字(使用配置的文字)
footer.setTitle(idleLoadingText, for: .idle) // 默认状态
footer.setTitle(releaseLoadingText, for: .pulling) // 上拉中
footer.setTitle(loadMoreText, for: .refreshing) // 正在加载
footer.setTitle("", for: .willRefresh) // 即将加载
footer.setTitle(noMoreDataText, for: .noMoreData) // 没有更多数据
footer.isHidden = !loadMoreEnabled
scrollView.mj_footer = footer
hasSetupRefreshControls = true
print("✅ PullRefresh: 刷新控件设置成功")
}
// 结束刷新(线程安全)
func endRefresh() {
DispatchQueue.main.async { [weak self] in
guard let self = self else { return }
self.syncQueue.async {
guard self.isRefreshing else { return }
self.isRefreshing = false
}
self.targetScrollView?.mj_header?.endRefreshing()
}
}
// 结束加载更多(线程安全)
func endLoadMore() {
DispatchQueue.main.async { [weak self] in
guard let self = self else { return }
self.syncQueue.async {
guard self.isLoadingMore else { return }
self.isLoadingMore = false
}
self.targetScrollView?.mj_footer?.endRefreshing()
}
}
// 没有更多数据(线程安全)
func noMoreData() {
DispatchQueue.main.async { [weak self] in
guard let self = self else { return }
self.syncQueue.async {
self.isLoadingMore = false
}
self.targetScrollView?.mj_footer?.endRefreshingWithNoMoreData()
}
}
// 重置没有更多数据状态(线程安全)
func resetNoMoreData() {
DispatchQueue.main.async { [weak self] in
guard let self = self else { return }
self.targetScrollView?.mj_footer?.resetNoMoreData()
}
}
// 启用/禁用下拉刷新
func setRefreshEnabled(_ enabled: Bool) {
refreshEnabled = enabled
DispatchQueue.main.async { [weak self] in
self?.targetScrollView?.mj_header?.isHidden = !enabled
}
}
// 启用/禁用上拉加载
func setLoadMoreEnabled(_ enabled: Bool) {
loadMoreEnabled = enabled
DispatchQueue.main.async { [weak self] in
self?.targetScrollView?.mj_footer?.isHidden = !enabled
}
}
// 设置刷新文字
func setRefreshingText(_ text: String) {
refreshingText = text
DispatchQueue.main.async { [weak self] in
if let header = self?.targetScrollView?.mj_header as? MJRefreshNormalHeader {
// 只更新正在刷新状态的文字
header.setTitle(text, for: .refreshing)
}
}
}
// 设置加载更多文字
func setLoadMoreText(_ text: String) {
loadMoreText = text
DispatchQueue.main.async { [weak self] in
if let footer = self?.targetScrollView?.mj_footer as? MJRefreshAutoNormalFooter {
// 只更新正在加载状态的文字
footer.setTitle(text, for: .refreshing)
}
}
}
// 通过key控制刷新状态
func setRefreshingKey(_ key: String) {
if key != currentRefreshingKey && !key.isEmpty {
currentRefreshingKey = key
endRefresh()
}
}
// 通过key控制加载状态
func setLoadingKey(_ key: String) {
if key != currentLoadingKey && !key.isEmpty {
currentLoadingKey = key
endLoadMore()
}
}
// 设置没有更多数据状态
func setNoMoreDataFlag(_ flag: Bool) {
if flag != currentNoMoreData {
currentNoMoreData = flag
if flag {
noMoreData()
} else {
resetNoMoreData()
}
}
}
override func layoutSubviews() {
super.layoutSubviews()
// 优化:只在需要时调整子视图大小
if bounds.size != .zero {
for subview in subviews where subview.frame.size != bounds.size {
subview.frame = bounds
}
}
}
override func insertReactSubview(_ subview: UIView!, at atIndex: Int) {
super.insertReactSubview(subview, at: atIndex)
addSubview(subview)
// 设置子视图frame
subview.frame = bounds
subview.autoresizingMask = [.flexibleWidth, .flexibleHeight]
// 优化:使用 DispatchQueue.main.async 而非延迟,更可靠
DispatchQueue.main.async { [weak self] in
self?.trySetupScrollView()
}
}
override func removeReactSubview(_ subview: UIView!) {
super.removeReactSubview(subview)
// 修复:检查 targetScrollView 是否是被移除视图的子孙
if let scrollView = targetScrollView {
// 递归检查 scrollView 是否在 subview 的子树中
if isView(scrollView, descendantOf: subview) {
targetScrollView = nil
hasSetupRefreshControls = false
print("⚠️ PullRefresh: ScrollView 已被移除,清除引用")
}
}
subview.removeFromSuperview()
}
// 辅助方法:检查一个视图是否是另一个视图的子孙
private func isView(_ view: UIView, descendantOf parentView: UIView) -> Bool {
var currentView: UIView? = view
while let current = currentView {
if current == parentView {
return true
}
currentView = current.superview
}
return false
}
// 尝试查找并设置ScrollView(优化版本)
private func trySetupScrollView() {
// 如果已经找到并设置过了,检查是否还有效
if let existingScrollView = targetScrollView {
// 检查 ScrollView 是否还在视图树中
if existingScrollView.superview == nil {
print("⚠️ PullRefresh: 检测到 ScrollView 已从视图树移除")
targetScrollView = nil
hasSetupRefreshControls = false
} else {
// ScrollView 仍然有效,不需要重新设置
return
}
}
// 在子视图中查找ScrollView
for subview in subviews {
if let scrollView = findScrollView(in: subview) {
targetScrollView = scrollView
setupRefreshControls(on: scrollView)
print("✅ PullRefresh: 找到 ScrollView 类型: \(type(of: scrollView))")
return
}
}
// 如果还是没找到,延迟重试一次(React Native 视图树可能还在构建中)
if targetScrollView == nil {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { [weak self] in
guard let self = self, self.targetScrollView == nil else { return }
for subview in self.subviews {
if let scrollView = self.findScrollView(in: subview) {
self.targetScrollView = scrollView
self.setupRefreshControls(on: scrollView)
print("✅ PullRefresh: 延迟查找成功,找到 ScrollView")
return
}
}
print("⚠️ PullRefresh: 未找到 ScrollView,请确保子组件包含可滚动视图(ScrollView/FlatList等)")
}
}
}
// 当视图添加到窗口时,再次尝试查找ScrollView
override func didMoveToWindow() {
super.didMoveToWindow()
if window != nil {
trySetupScrollView()
}
}
// 清理资源
deinit {
// 移除 MJRefresh 控件
targetScrollView?.mj_header = nil
targetScrollView?.mj_footer = nil
print("🔄 PullRefresh: 视图已销毁,清理资源")
}
}
2.1.3 src/PullRefresh.types.ts
编写src代码,AI开发modules/pull-refresh/src/PullRefresh.types.ts
import type { ReactNode } from 'react';
import type { StyleProp, ViewStyle } from 'react-native';
// 下拉刷新事件回调
export type OnRefreshEventPayload = {
// 可以添加额外的事件数据
};
// 上拉加载事件回调
export type OnLoadMoreEventPayload = {
// 可以添加额外的事件数据
};
// PullRefresh视图组件的Props
export type PullRefreshViewProps = {
// 子组件
children?: ReactNode;
// 样式
style?: StyleProp<ViewStyle>;
// 是否启用下拉刷新,默认true
refreshEnabled?: boolean;
// 是否启用上拉加载,默认true
loadMoreEnabled?: boolean;
// 下拉刷新的文字提示
refreshingText?: string;
// 上拉加载的文字提示
loadMoreText?: string;
// 下拉刷新回调
onRefresh?: () => void;
// 上拉加载回调
onLoadMore?: () => void;
// 控制刷新状态的ID,每次改变会触发刷新结束
refreshingKey?: string | number;
// 控制加载状态的ID,每次改变会触发加载结束
loadingKey?: string | number;
// 是否显示"没有更多数据"
noMoreData?: boolean;
};
编写src代码,AI开发modules/pull-refresh/src/PullRefreshComponent.tsx
import * as React from 'react';
import { PullRefreshViewProps } from './PullRefresh.types';
import PullRefreshView from './PullRefreshView';
// Ref控制方法接口
export interface PullRefreshRef {
// 结束下拉刷新
endRefresh: () => void;
// 结束上拉加载
endLoadMore: () => void;
// 设置没有更多数据
noMoreData: () => void;
// 重置没有更多数据状态
resetNoMoreData: () => void;
}
/**
* 带Ref控制的PullRefresh组件
*
* 使用forwardRef支持通过ref调用控制方法
*
* @example
* ```tsx
* const pullRefreshRef = useRef<PullRefreshRef>(null);
*
* const handleRefresh = async () => {
* await fetchData();
* pullRefreshRef.current?.endRefresh();
* };
*
* <PullRefresh
* ref={pullRefreshRef}
* onRefresh={handleRefresh}
* >
* <YourContent />
* </PullRefresh>
* ```
*/
const PullRefresh = React.forwardRef<PullRefreshRef, PullRefreshViewProps>(
(props, ref) => {
// 使用状态来触发刷新结束
const [refreshingKey, setRefreshingKey] = React.useState('');
const [loadingKey, setLoadingKey] = React.useState('');
const [hasNoMoreData, setHasNoMoreData] = React.useState(false);
// 暴露控制方法给外部
React.useImperativeHandle(ref, () => ({
endRefresh: () => {
// 改变key来触发刷新结束
setRefreshingKey(Date.now().toString());
},
endLoadMore: () => {
// 改变key来触发加载结束
setLoadingKey(Date.now().toString());
},
noMoreData: () => {
setHasNoMoreData(true);
setLoadingKey(Date.now().toString());
},
resetNoMoreData: () => {
setHasNoMoreData(false);
},
}));
return (
<PullRefreshView
{...props}
refreshingKey={refreshingKey}
loadingKey={loadingKey}
noMoreData={hasNoMoreData}
/>
);
}
);
PullRefresh.displayName = 'PullRefresh';
export default PullRefresh;
2.1.4 src/PullRefreshModule.ts
编写src代码,AI开发modules/pull-refresh/src/PullRefreshModule.ts
import { NativeModule, requireNativeModule } from 'expo';
// 定义原生模块接口
declare class PullRefreshModule extends NativeModule {
// 这里可以添加模块级别的方法(如果需要)
}
// 加载原生模块
const nativeModule = requireNativeModule<PullRefreshModule>('PullRefresh');
export default nativeModule;
2.1.5 src/PullRefreshView.ts
编写src代码,AI开发modules/pull-refresh/src/PullRefreshView.tsx
import { requireNativeView } from 'expo';
import * as React from 'react';
import { View } from 'react-native';
import { PullRefreshViewProps } from './PullRefresh.types';
// 导入原生视图 - 使用any类型避免类型问题
const NativeView: any = requireNativeView('PullRefresh');
/**
* PullRefresh 下拉刷新组件
*
* 跨平台原生下拉刷新和上拉加载体验:
* - iOS: 使用 MJRefresh 库
* - Android: 使用 SmartRefreshLayout 库
*
* @example
* ```tsx
* <PullRefreshView
* refreshEnabled={true}
* loadMoreEnabled={true}
* onRefresh={handleRefresh}
* onLoadMore={handleLoadMore}
* >
* <FlatList data={data} ... />
* </PullRefreshView>
* ```
*/
const PullRefreshView = React.forwardRef<View, PullRefreshViewProps>(
(props, ref) => {
const {
children,
style,
refreshEnabled = true,
loadMoreEnabled = true,
refreshingText = '正在刷新...',
loadMoreText = '加载更多...',
onRefresh,
onLoadMore,
...otherProps
} = props;
// 处理下拉刷新事件
const handleRefresh = React.useCallback(() => {
if (onRefresh) {
onRefresh();
}
}, [onRefresh]);
// 处理上拉加载事件
const handleLoadMore = React.useCallback(() => {
if (onLoadMore) {
onLoadMore();
}
}, [onLoadMore]);
return (
<NativeView
ref={ref}
style={style}
refreshEnabled={refreshEnabled}
loadMoreEnabled={loadMoreEnabled}
refreshingText={refreshingText}
loadMoreText={loadMoreText}
onRefresh={handleRefresh}
onLoadMore={handleLoadMore}
{...otherProps}
>
{children}
</NativeView>
);
}
);
PullRefreshView.displayName = 'PullRefreshView';
export default PullRefreshView;
2.1.6 modules/pull-refresh/index.ts
编写src代码,AI开发modules/pull-refresh/index.ts
// 导出主要的PullRefresh组件(带ref支持)
export { default, PullRefreshRef } from './src/PullRefreshComponent';
// 导出基础视图组件
export { default as PullRefreshView } from './src/PullRefreshView';
// 导出原生模块(如果需要直接访问)
export { default as PullRefreshModule } from './src/PullRefreshModule';
// 导出类型定义
export * from './src/PullRefresh.types';
2.2 android集成 SmartRefreshLayout
修改modules/pull-refresh/android/build.gradle文件,加入SmartRefreshLayout相关的库,如下面截图所示
接着准备编写原生android代码
2.3.1 PullRefreshModule.kt
使用AI开发modules/pull-refresh/and roid/src/main/java/expo/modules/pullrefresh/PullRefreshModule.kt
package expo.modules.pullrefresh
import expo.modules.kotlin.modules.Module
import expo.modules.kotlin.modules.ModuleDefinition
/**
* PullRefresh 模块定义
*
* 功能对标 iOS 版本:
* - 提供下拉刷新和上拉加载功能
* - 通过 Props 控制状态
* - 通过 Events 发送事件
* - 使用 SmartRefreshLayout 库实现
*/
class PullRefreshModule : Module() {
override fun definition() = ModuleDefinition {
// 模块名称
Name("PullRefresh")
// 定义视图组件
View(PullRefreshView::class) {
// 事件定义
Events("onRefresh", "onLoadMore")
// Props定义 - 功能控制
Prop("refreshEnabled") { view: PullRefreshView, enabled: Boolean ->
view.setRefreshEnabled(enabled)
}
Prop("loadMoreEnabled") { view: PullRefreshView, enabled: Boolean ->
view.setLoadMoreEnabled(enabled)
}
// Props定义 - 文字设置
Prop("refreshingText") { view: PullRefreshView, text: String ->
view.setRefreshingText(text)
}
Prop("loadMoreText") { view: PullRefreshView, text: String ->
view.setLoadMoreText(text)
}
// Props定义 - 状态控制
Prop("refreshingKey") { view: PullRefreshView, key: String ->
view.setRefreshingKey(key)
}
Prop("loadingKey") { view: PullRefreshView, key: String ->
view.setLoadingKey(key)
}
Prop("noMoreData") { view: PullRefreshView, flag: Boolean ->
view.setNoMoreDataFlag(flag)
}
// 设置事件回调
OnViewDidUpdateProps { view: PullRefreshView ->
// 当Props更新时,可以在这里做额外处理
}
}
}
}
2.3.2 PullRefreshView.kt
使用AI开发modules/pull-refresh/and roid/src/main/java/expo/modules/pullrefresh/PullRefreshView.kt
package expo.modules.pullrefresh
import android.content.Context
import android.os.Handler
import android.os.Looper
import android.view.View
import android.view.ViewGroup
import androidx.core.widget.NestedScrollView
import androidx.recyclerview.widget.RecyclerView
import com.scwang.smart.refresh.layout.SmartRefreshLayout
import com.scwang.smart.refresh.layout.api.RefreshLayout
import com.scwang.smart.refresh.layout.constant.SpinnerStyle
import com.scwang.smart.refresh.layout.listener.OnLoadMoreListener
import com.scwang.smart.refresh.layout.listener.OnRefreshListener
import com.scwang.smart.refresh.footer.BallPulseFooter
import expo.modules.kotlin.AppContext
import expo.modules.kotlin.viewevent.EventDispatcher
import expo.modules.kotlin.views.ExpoView
/**
* PullRefresh视图组件,集成SmartRefreshLayout功能
*
* 功能对标iOS版本:
* - 自动查找可滚动视图(ScrollView、RecyclerView等)
* - 支持下拉刷新和上拉加载
* - 通过Props控制状态
* - 通过Events发送事件
* - 线程安全
*/
class PullRefreshView(context: Context, appContext: AppContext) : ExpoView(context, appContext) {
// SmartRefreshLayout实例
private var refreshLayout: SmartRefreshLayout? = null
// 刷新状态标记
private var isRefreshing = false
private var isLoadingMore = false
// 用于追踪状态变化的key
private var currentRefreshingKey: String = ""
private var currentLoadingKey: String = ""
private var currentNoMoreData: Boolean = false
// 存储配置,等待找到ScrollView后应用
private var refreshEnabled = true
private var loadMoreEnabled = true
private var refreshingText = "正在刷新..."
private var loadMoreText = "加载更多..."
private var idlePullingText = "下拉刷新"
private var releasePullingText = "释放立即刷新"
private var idleLoadingText = "点击或上拉加载更多"
private var releaseLoadingText = "释放立即加载"
private var noMoreDataText = "没有更多数据了"
// 是否已经设置过刷新控件
private var hasSetupRefreshControls = false
// 主线程Handler
private val mainHandler = Handler(Looper.getMainLooper())
// 事件分发器
private val onRefresh by EventDispatcher()
private val onLoadMore by EventDispatcher()
init {
// 初始化时不创建SmartRefreshLayout,等待子视图添加
}
/**
* 递归查找第一个可滚动视图(RecyclerView、ScrollView、NestedScrollView等)
* 使用广度优先搜索算法,添加深度限制
*/
private fun findScrollView(view: View, depth: Int = 0, maxDepth: Int = 10): View? {
// 防止过深的递归
if (depth >= maxDepth) return null
// 检查当前视图是否是可滚动视图
if (isScrollableView(view)) {
return view
}
// 如果是ViewGroup,先检查直接子视图(广度优先)
if (view is ViewGroup) {
for (i in 0 until view.childCount) {
val child = view.getChildAt(i)
if (isScrollableView(child)) {
return child
}
}
// 如果直接子视图中没有,再递归搜索
for (i in 0 until view.childCount) {
val child = view.getChildAt(i)
findScrollView(child, depth + 1, maxDepth)?.let {
return it
}
}
}
return null
}
/**
* 判断视图是否是可滚动视图
*/
private fun isScrollableView(view: View): Boolean {
return view is RecyclerView ||
view is android.widget.ScrollView ||
view is NestedScrollView ||
view is android.widget.ListView ||
view is android.widget.GridView
}
/**
* 在找到的可滚动视图上设置SmartRefreshLayout
*/
private fun setupRefreshControls() {
// 防止重复设置
if (hasSetupRefreshControls) {
println("⚠️ PullRefresh: 刷新控件已设置,跳过重复设置")
return
}
// 如果已经有SmartRefreshLayout,先移除
refreshLayout?.let {
removeView(it)
}
// 创建SmartRefreshLayout
val layout = SmartRefreshLayout(context).apply {
layoutParams = LayoutParams(
LayoutParams.MATCH_PARENT,
LayoutParams.MATCH_PARENT
)
// 使用自定义的 Lottie Header,实现类似 SmartRefreshLottie 的动画效果
val header = LottieRefreshHeader(context)
setRefreshHeader(header)
// 设置上拉加载 Footer 为 SmartRefreshLayout 示例中的「球脉冲」样式
val footer = BallPulseFooter(context).apply {
setSpinnerStyle(SpinnerStyle.Scale)
setAnimatingColor(android.graphics.Color.GRAY)
}
setRefreshFooter(footer)
// 设置下拉刷新监听
setOnRefreshListener(object : OnRefreshListener {
override fun onRefresh(layout: RefreshLayout) {
this@PullRefreshView.isRefreshing = true
mainHandler.post {
this@PullRefreshView.onRefresh(emptyMap<String, Any>())
}
}
})
// 设置上拉加载监听
setOnLoadMoreListener(object : OnLoadMoreListener {
override fun onLoadMore(layout: RefreshLayout) {
this@PullRefreshView.isLoadingMore = true
mainHandler.post {
this@PullRefreshView.onLoadMore(emptyMap<String, Any>())
}
}
})
// 应用初始配置
setEnableRefresh(refreshEnabled)
setEnableLoadMore(loadMoreEnabled)
}
// 将所有现有子视图移动到SmartRefreshLayout中
val childrenToMove = mutableListOf<View>()
for (i in 0 until childCount) {
childrenToMove.add(getChildAt(i))
}
removeAllViews()
// 将子视图添加到SmartRefreshLayout
childrenToMove.forEach { child ->
layout.addView(child)
}
// 将SmartRefreshLayout添加到当前视图
addView(layout)
refreshLayout = layout
hasSetupRefreshControls = true
// 更新文字
updateRefreshTexts()
println("✅ PullRefresh: 刷新控件设置成功")
}
/**
* 更新刷新相关文字
*/
private fun updateRefreshTexts() {
// 当前使用 BezierRadarHeader + BallPulseFooter,它们内部自带文案和动画效果,
// SmartRefreshLayout 暂未提供稳定的多状态文本设置 API,因此这里暂不修改文案,
// 如需进一步自定义,可在后续版本中通过自定义 Header/Footer 实现。
}
/**
* 结束下拉刷新(线程安全)
*/
fun endRefresh() {
mainHandler.post {
if (isRefreshing) {
isRefreshing = false
refreshLayout?.finishRefresh()
}
}
}
/**
* 结束上拉加载(线程安全)
*/
fun endLoadMore() {
mainHandler.post {
if (isLoadingMore) {
isLoadingMore = false
refreshLayout?.finishLoadMore()
}
}
}
/**
* 没有更多数据(线程安全)
*/
fun noMoreData() {
mainHandler.post {
isLoadingMore = false
refreshLayout?.finishLoadMoreWithNoMoreData()
}
}
/**
* 重置没有更多数据状态(线程安全)
*/
fun resetNoMoreData() {
mainHandler.post {
refreshLayout?.setNoMoreData(false)
}
}
/**
* 启用/禁用下拉刷新
*/
fun setRefreshEnabled(enabled: Boolean) {
refreshEnabled = enabled
mainHandler.post {
refreshLayout?.setEnableRefresh(enabled)
}
}
/**
* 启用/禁用上拉加载
*/
fun setLoadMoreEnabled(enabled: Boolean) {
loadMoreEnabled = enabled
mainHandler.post {
refreshLayout?.setEnableLoadMore(enabled)
}
}
/**
* 设置刷新文字
*/
fun setRefreshingText(text: String) {
refreshingText = text
mainHandler.post {
updateRefreshTexts()
}
}
/**
* 设置加载更多文字
*/
fun setLoadMoreText(text: String) {
loadMoreText = text
mainHandler.post {
updateRefreshTexts()
}
}
/**
* 通过key控制刷新状态
*/
fun setRefreshingKey(key: String) {
if (key != currentRefreshingKey && key.isNotEmpty()) {
currentRefreshingKey = key
endRefresh()
}
}
/**
* 通过key控制加载状态
*/
fun setLoadingKey(key: String) {
if (key != currentLoadingKey && key.isNotEmpty()) {
currentLoadingKey = key
endLoadMore()
}
}
/**
* 设置没有更多数据状态
*/
fun setNoMoreDataFlag(flag: Boolean) {
if (flag != currentNoMoreData) {
currentNoMoreData = flag
if (flag) {
noMoreData()
} else {
resetNoMoreData()
}
}
}
/**
* 当子视图添加时触发
*/
override fun addView(child: View?, index: Int, params: ViewGroup.LayoutParams?) {
if (child is SmartRefreshLayout) {
// 如果是SmartRefreshLayout,直接添加
super.addView(child, index, params)
} else {
// 如果还没有设置SmartRefreshLayout
if (refreshLayout == null) {
super.addView(child, index, params)
// 延迟尝试设置ScrollView
mainHandler.post {
trySetupScrollView()
}
} else {
// 如果已经有SmartRefreshLayout,将子视图添加到它里面
refreshLayout?.addView(child, params)
}
}
}
/**
* 尝试查找并设置ScrollView
*/
private fun trySetupScrollView() {
// 如果已经设置过了,不需要再次设置
if (hasSetupRefreshControls) {
return
}
// 检查是否有子视图
if (childCount == 0) {
return
}
// 查找可滚动视图
var foundScrollView = false
for (i in 0 until childCount) {
val child = getChildAt(i)
if (findScrollView(child) != null) {
foundScrollView = true
break
}
}
if (foundScrollView) {
setupRefreshControls()
println("✅ PullRefresh: 找到可滚动视图")
} else {
// 如果还没找到,延迟重试一次
mainHandler.postDelayed({
if (!hasSetupRefreshControls && childCount > 0) {
for (i in 0 until childCount) {
val child = getChildAt(i)
if (findScrollView(child) != null) {
setupRefreshControls()
println("✅ PullRefresh: 延迟查找成功,找到可滚动视图")
return@postDelayed
}
}
println("⚠️ PullRefresh: 未找到可滚动视图,请确保子组件包含可滚动视图(ScrollView/FlatList等)")
}
}, 50)
}
}
/**
* 清理资源
*/
override fun onDetachedFromWindow() {
super.onDetachedFromWindow()
// 清理Handler消息
mainHandler.removeCallbacksAndMessages(null)
// 清理SmartRefreshLayout
refreshLayout?.let {
it.setOnRefreshListener(null)
it.setOnLoadMoreListener(null)
}
println("🔄 PullRefresh: 视图已销毁,清理资源")
}
}
2.3 集成到expo demo演示页面
app/(tabs)/demo.tsx,代码如下:
import PullRefresh, { PullRefreshRef } from '@/modules/pull-refresh';
import React, { useEffect, useRef, useState } from 'react';
import { FlatList, ListRenderItem, StatusBar, StyleSheet, Text, View } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
interface DataItem {
id: number;
title: string;
subtitle: string;
}
export default function PullRefreshDemo() {
const pullRefreshRef = useRef<PullRefreshRef>(null);
const [data, setData] = useState<DataItem[]>([]);
const [page, setPage] = useState(0);
const [hasMore, setHasMore] = useState(true);
const [isLoading, setIsLoading] = useState(false);
// 初次加载数据
useEffect(() => {
loadInitialData();
}, []);
// 初始加载
const loadInitialData = async () => {
try {
setIsLoading(true);
const newData = await mockFetchData(1);
setData(newData);
setPage(1);
setHasMore(true);
} catch (error) {
console.error('初始加载失败:', error);
} finally {
setIsLoading(false);
}
};
// 模拟API请求
const mockFetchData = async (pageNum: number): Promise<DataItem[]> => {
// 模拟网络延迟
const start = Date.now();
console.log(`[${new Date().toLocaleTimeString()}] 模拟网络延迟...`);
await new Promise(resolve => setTimeout(resolve, 2500));
const end = Date.now();
console.log(`[${new Date().toLocaleTimeString()}] 模拟网络延迟结束,耗时${end - start}ms`);
const newData: DataItem[] = Array.from({ length: 10 }, (_, i) => ({
id: (pageNum - 1) * 10 + i + 1,
title: `数据项 ${(pageNum - 1) * 10 + i + 1}`,
subtitle: `这是第 ${pageNum} 页的第 ${i + 1} 条数据`,
}));
return newData;
};
// 下拉刷新
const handleRefresh = async () => {
// 防止重复加载
if (isLoading) return;
try {
setIsLoading(true);
console.log('开始刷新...');
const newData = await mockFetchData(1);
setData(newData);
setPage(1);
setHasMore(true);
// 重置没有更多数据状态
pullRefreshRef.current?.resetNoMoreData();
console.log('刷新完成');
} catch (error) {
console.error('刷新失败:', error);
} finally {
setIsLoading(false);
// 结束刷新动画
pullRefreshRef.current?.endRefresh();
}
};
// 上拉加载更多
const handleLoadMore = async () => {
// 防止重复加载
if (isLoading || !hasMore) {
console.log('加载中或没有更多数据');
return;
}
try {
setIsLoading(true);
console.log('开始加载更多...');
const nextPage = page + 1;
const newData = await mockFetchData(nextPage);
if (nextPage >= 5) {
// 模拟:第5页后没有更多数据
console.log('已加载全部数据');
setHasMore(false);
pullRefreshRef.current?.noMoreData();
} else {
setData([...data, ...newData]);
setPage(nextPage);
console.log(`加载完成,当前第 ${nextPage} 页`);
pullRefreshRef.current?.endLoadMore();
}
} catch (error) {
console.error('加载更多失败:', error);
pullRefreshRef.current?.endLoadMore();
} finally {
setIsLoading(false);
}
};
// 渲染列表项
const renderItem: ListRenderItem<DataItem> = ({ item }) => (
<View style={styles.card}>
<View style={styles.cardHeader}>
<View style={styles.badge}>
<Text style={styles.badgeText}>#{item.id}</Text>
</View>
<Text style={styles.cardTitle}>{item.title}</Text>
</View>
<Text style={styles.cardSubtitle}>{item.subtitle}</Text>
<View style={styles.cardFooter}>
<Text style={styles.cardFooterText}>
{new Date().toLocaleTimeString('zh-CN')}
</Text>
</View>
</View>
);
// 列表头部组件
const ListHeaderComponent = () => (
<>
{/* 统计信息 */}
<View style={styles.statsCard}>
<View style={styles.statItem}>
<Text style={styles.statValue}>{data.length}</Text>
<Text style={styles.statLabel}>数据总数</Text>
</View>
<View style={styles.divider} />
<View style={styles.statItem}>
<Text style={styles.statValue}>{page}</Text>
<Text style={styles.statLabel}>当前页数</Text>
</View>
<View style={styles.divider} />
<View style={styles.statItem}>
<Text style={styles.statValue}>{hasMore ? '是' : '否'}</Text>
<Text style={styles.statLabel}>还有更多</Text>
</View>
</View>
{/* 提示卡片 */}
<View style={styles.tipCard}>
<Text style={styles.tipIcon}>💡</Text>
<View style={styles.tipContent}>
<Text style={styles.tipTitle}>使用提示</Text>
<Text style={styles.tipText}>
向下拉动刷新数据{'\n'}
滚动到底部自动加载更多{'\n'}
加载到第5页后将显示"没有更多数据"
</Text>
</View>
</View>
</>
);
// 空列表组件
const ListEmptyComponent = () => (
<View style={styles.emptyContainer}>
<Text style={styles.emptyIcon}>📦</Text>
<Text style={styles.emptyText}>暂无数据</Text>
<Text style={styles.emptySubtext}>下拉刷新加载数据</Text>
</View>
);
return (
<SafeAreaView style={styles.safeArea}>
<StatusBar barStyle="dark-content" />
{/* 标题栏 */}
<View style={styles.header}>
<Text style={styles.headerTitle}>PullRefresh 示例</Text>
<Text style={styles.headerSubtitle}>下拉刷新 • 上拉加载</Text>
</View>
{/* 下拉刷新组件 */}
<PullRefresh
ref={pullRefreshRef}
style={styles.container}
refreshEnabled={true}
loadMoreEnabled={true}
refreshingText="正在刷新..."
loadMoreText="加载更多..."
onRefresh={handleRefresh}
onLoadMore={handleLoadMore}
>
<FlatList
data={data}
renderItem={renderItem}
keyExtractor={(item) => item.id.toString()}
ListHeaderComponent={ListHeaderComponent}
ListEmptyComponent={ListEmptyComponent}
showsVerticalScrollIndicator={true}
contentContainerStyle={styles.flatListContent}
// 性能优化
removeClippedSubviews={true}
maxToRenderPerBatch={10}
updateCellsBatchingPeriod={50}
windowSize={10}
initialNumToRender={10}
/>
</PullRefresh>
</SafeAreaView>
);
}
const styles = StyleSheet.create({
safeArea: {
flex: 1,
backgroundColor: '#f8f9fa',
},
header: {
backgroundColor: '#fff',
paddingHorizontal: 20,
paddingTop: 16,
paddingBottom: 16,
borderBottomWidth: 1,
borderBottomColor: '#e9ecef',
},
headerTitle: {
fontSize: 28,
fontWeight: 'bold',
color: '#212529',
marginBottom: 4,
},
headerSubtitle: {
fontSize: 14,
color: '#6c757d',
},
container: {
flex: 1,
},
flatListContent: {
flexGrow: 1,
},
statsCard: {
flexDirection: 'row',
backgroundColor: '#fff',
borderRadius: 12,
padding: 20,
margin: 16,
marginBottom: 8,
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.05,
shadowRadius: 8,
elevation: 2,
},
statItem: {
flex: 1,
alignItems: 'center',
},
statValue: {
fontSize: 32,
fontWeight: 'bold',
color: '#007bff',
marginBottom: 4,
},
statLabel: {
fontSize: 12,
color: '#6c757d',
},
divider: {
width: 1,
backgroundColor: '#e9ecef',
marginHorizontal: 8,
},
tipCard: {
flexDirection: 'row',
backgroundColor: '#e7f3ff',
borderRadius: 12,
padding: 16,
marginHorizontal: 16,
marginBottom: 8,
},
tipIcon: {
fontSize: 24,
marginRight: 12,
},
tipContent: {
flex: 1,
},
tipTitle: {
fontSize: 16,
fontWeight: '600',
color: '#0056b3',
marginBottom: 4,
},
tipText: {
fontSize: 13,
color: '#004085',
lineHeight: 20,
},
card: {
backgroundColor: '#fff',
borderRadius: 12,
padding: 16,
marginHorizontal: 16,
marginBottom: 12,
shadowColor: '#000',
shadowOffset: { width: 0, height: 1 },
shadowOpacity: 0.05,
shadowRadius: 4,
elevation: 2,
},
cardHeader: {
flexDirection: 'row',
alignItems: 'center',
marginBottom: 8,
},
badge: {
backgroundColor: '#007bff',
borderRadius: 12,
paddingHorizontal: 8,
paddingVertical: 4,
marginRight: 8,
},
badgeText: {
fontSize: 12,
fontWeight: '600',
color: '#fff',
},
cardTitle: {
fontSize: 17,
fontWeight: '600',
color: '#212529',
flex: 1,
},
cardSubtitle: {
fontSize: 14,
color: '#6c757d',
marginBottom: 12,
lineHeight: 20,
},
cardFooter: {
borderTopWidth: 1,
borderTopColor: '#e9ecef',
paddingTop: 12,
},
cardFooterText: {
fontSize: 12,
color: '#adb5bd',
},
emptyContainer: {
alignItems: 'center',
justifyContent: 'center',
paddingVertical: 60,
},
emptyIcon: {
fontSize: 64,
marginBottom: 16,
},
emptyText: {
fontSize: 18,
fontWeight: '600',
color: '#495057',
marginBottom: 8,
},
emptySubtext: {
fontSize: 14,
color: '#adb5bd',
},
});
yarn android运行成功截图