Expo框架(一)- React Native0.81集成原生PullRefresh下拉刷新和上拉加载功能

120 阅读16分钟

引言

ReactNative的很多第三方模块都不怎么维护了,现在质量有保障的是Expo官方提供的一系列库,比如expo-image可以替换很久没维护的react-native-fast-image,所以感觉RN开发的趋势就是Expo框架了。

我以前还挺担心Expo框架的一些第三方原生模块的集成和打包,其实当捋清楚它的功能和文档以后 还挺好用的,不用担心打包和第三方库的问题

集成第三方原生库(比如支付宝、微信、友盟、极光推送)等,打包可以prebuild出ios和安卓目录,然后用XcodeAndroid打包即可。

第三方库的集成原生代码可以使用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文件,比如截图所示:

image.png

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

image.png

接着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相关的库,如下面截图所示

image.png

接着准备编写原生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演示页面

image.png

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运行成功截图

image.png

2.4 ios演示

ios-pullRefresh示例.gif

2.5 android演示 - lottie头动画

android-pullRefresh示例.gif

代码仓库地址

github.com/Infiee/Reac…

相关链接

github.com/scwang90/Sm…

github.com/CoderMJLee/…