欢迎加入开源鸿蒙跨平台社区:https://openharmonycrossplatform.csdn.net

在这里插入图片描述

📋 前言

rn-placeholder 是一个优雅的骨架屏加载占位符库,用于在数据加载时显示占位内容,提供流畅的用户体验。它支持多种动画效果,包括淡入淡出、闪光、渐进式加载等,可以自定义占位符的样式和布局,完全兼容 Android、iOS、Web 和 HarmonyOS 多平台。

🎯 库简介

基本信息

  • 库名称: rn-placeholder
  • 版本信息:
    • 3.0.3: 支持 RN 0.72/0.77 版本
  • 官方仓库: https://github.com/mfrachet/rn-placeholder
  • 主要功能:
    • 多种动画效果(Fade、Shine、ShineOverlay、Loader、Progressive)
    • 自定义占位符布局
    • 占位线条和媒体组件
    • 左右媒体占位
    • 完全可自定义样式
    • 支持嵌套布局
    • 轻量级,无额外依赖
  • 兼容性验证:
    • RNOH: 0.72.27; SDK: HarmonyOS-Next-DB1 5.0.0.29(SP1); IDE: DevEco Studio 5.0.3.400; ROM: 3.0.0.25;
    • RNOH: 0.77.18; SDK: HarmonyOS 5.1.1.208 (API Version 19 Release); IDE: DevEco Studio 5.1.1.830; ROM: HarmonyOS 6.0.0.112 SP12;

为什么需要这个库?

  • 用户体验: 提供优雅的加载占位符,避免白屏
  • 视觉连贯: 占位符与实际内容布局一致,减少视觉跳跃
  • 性能优化: 轻量级实现,不影响应用性能
  • 易于使用: API 简单直观,集成快速
  • 高度可定制: 支持自定义样式和动画效果
  • 专业级: 数据密集型应用必备优化方案

📦 安装步骤

1. 使用 npm 安装

npm install rn-placeholder@3.0.3 --legacy-peer-deps

2. 验证安装

安装完成后,检查 package.json 文件,应该能看到新增的依赖:

{
  "dependencies": {
    "rn-placeholder": "^3.0.3",
    // ... 其他依赖
  }
}

🔧 HarmonyOS 平台配置 ⭐

重要说明

rn-placeholder 是纯 JavaScript 实现,无需任何原生配置,安装后即可直接使用。

配置步骤

无需配置!安装完成后即可直接使用。

注意: 由于 rn-placeholder 是纯 JavaScript 实现,不需要进行 CMakeLists.txt、PackageProvider.cpp、RNPackagesFactory.ts 等原生配置。

💻 完整代码示例

下面是一个完整的示例,展示了 rn-placeholder 的各种使用场景:

import React, { useState } from 'react';
import {
  View,
  Text,
  StyleSheet,
  ScrollView,
  TouchableOpacity,
  SafeAreaView,
  FlatList,
} from 'react-native';
import {
  Placeholder,
  PlaceholderMedia,
  PlaceholderLine,
  Fade,
  Shine,
  ShineOverlay,
  Loader,
  Progressive,
} from 'rn-placeholder';

function PlaceholderDemo(): JSX.Element {
  const [loading, setLoading] = useState(true);
  const [animationType, setAnimationType] = useState<'Fade' | 'Shine' | 'ShineOverlay' | 'Loader' | 'Progressive'>('Fade');
  const [hasLeftRight, setHasLeftRight] = useState(false);

  const getAnimation = () => {
    switch (animationType) {
      case 'Fade':
        return Fade as React.ComponentType<any>;
      case 'Shine':
        return Shine as React.ComponentType<any>;
      case 'ShineOverlay':
        return ShineOverlay as React.ComponentType<any>;
      case 'Loader':
        return Loader as React.ComponentType<any>;
      case 'Progressive':
        return Progressive as React.ComponentType<any>;
      default:
        return Fade as React.ComponentType<any>;
    }
  };

  const toggleLoading = () => {
    setLoading(!loading);
  };

  const renderListItem = ({ item, index }: { item: any; index: number }) => {
    if (loading) {
      return (
        <View style={styles.listItem}>
          <Placeholder
            Animation={getAnimation()}
            Left={PlaceholderMedia}
            Right={hasLeftRight ? PlaceholderMedia : undefined}
            style={styles.listItemPlaceholder}
          >
            <PlaceholderLine width={80} />
            <PlaceholderLine width={60} />
            <PlaceholderLine width={40} />
          </Placeholder>
        </View>
      );
    }

    return (
      <View style={styles.listItem}>
        <View style={styles.listItemContent}>
          <Text style={styles.listItemTitle}>列表项 {index + 1}</Text>
          <Text style={styles.listItemSubtitle}>这是列表项的描述内容</Text>
          <Text style={styles.listItemText}>更多详细信息...</Text>
        </View>
        {hasLeftRight && (
          <View style={styles.listItemRight}>
            <View style={styles.listItemImage} />
          </View>
        )}
      </View>
    );
  };

  const renderCardItem = () => {
    if (loading) {
      return (
        <View style={styles.card}>
          <Placeholder Animation={getAnimation()} style={styles.cardPlaceholder}>
            <PlaceholderMedia isRound={true} size={60} style={styles.cardAvatar} />
            <View style={styles.cardContent}>
              <PlaceholderLine width={70} style={styles.cardTitle} />
              <PlaceholderLine width={50} />
              <PlaceholderLine width={90} />
              <PlaceholderLine width={30} />
            </View>
          </Placeholder>
        </View>
      );
    }

    return (
      <View style={styles.card}>
        <View style={styles.cardAvatar} />
        <View style={styles.cardContent}>
          <Text style={styles.cardTitle}>用户名称</Text>
          <Text style={styles.cardText}>这是用户的简介信息</Text>
          <Text style={styles.cardText}>更多详细信息可以在这里展示</Text>
          <Text style={styles.cardText}>点击查看更多</Text>
        </View>
      </View>
    );
  };

  const data = Array.from({ length: 5 }, (_, i) => ({ id: i }));

  return (
    <SafeAreaView style={styles.container}>
      <ScrollView style={styles.content} contentContainerStyle={styles.scrollContent}>
        <Text style={styles.title}>骨架屏占位符</Text>

        {/* 控制面板 */}
        <View style={styles.controlPanel}>
          <Text style={styles.controlTitle}>动画类型</Text>
          <View style={styles.animationButtons}>
            <TouchableOpacity
              style={[
                styles.animationButton,
                animationType === 'Fade' && styles.activeButton,
              ]}
              onPress={() => setAnimationType('Fade')}
            >
              <Text
                style={[
                  styles.animationButtonText,
                  animationType === 'Fade' && styles.activeButtonText,
                ]}
              >
                Fade
              </Text>
            </TouchableOpacity>
            <TouchableOpacity
              style={[
                styles.animationButton,
                animationType === 'Shine' && styles.activeButton,
              ]}
              onPress={() => setAnimationType('Shine')}
            >
              <Text
                style={[
                  styles.animationButtonText,
                  animationType === 'Shine' && styles.activeButtonText,
                ]}
              >
                Shine
              </Text>
            </TouchableOpacity>
            <TouchableOpacity
              style={[
                styles.animationButton,
                animationType === 'ShineOverlay' && styles.activeButton,
              ]}
              onPress={() => setAnimationType('ShineOverlay')}
            >
              <Text
                style={[
                  styles.animationButtonText,
                  animationType === 'ShineOverlay' && styles.activeButtonText,
                ]}
              >
                ShineOverlay
              </Text>
            </TouchableOpacity>
            <TouchableOpacity
              style={[
                styles.animationButton,
                animationType === 'Loader' && styles.activeButton,
              ]}
              onPress={() => setAnimationType('Loader')}
            >
              <Text
                style={[
                  styles.animationButtonText,
                  animationType === 'Loader' && styles.activeButtonText,
                ]}
              >
                Loader
              </Text>
            </TouchableOpacity>
            <TouchableOpacity
              style={[
                styles.animationButton,
                animationType === 'Progressive' && styles.activeButton,
              ]}
              onPress={() => setAnimationType('Progressive')}
            >
              <Text
                style={[
                  styles.animationButtonText,
                  animationType === 'Progressive' && styles.activeButtonText,
                ]}
              >
                Progressive
              </Text>
            </TouchableOpacity>
          </View>

          <View style={styles.toggleRow}>
            <TouchableOpacity
              style={styles.toggleButton}
              onPress={() => setHasLeftRight(!hasLeftRight)}
            >
              <Text style={styles.toggleButtonText}>
                {hasLeftRight ? '隐藏左右媒体' : '显示左右媒体'}
              </Text>
            </TouchableOpacity>
            <TouchableOpacity style={styles.toggleButton} onPress={toggleLoading}>
              <Text style={styles.toggleButtonText}>
                {loading ? '显示真实内容' : '显示骨架屏'}
              </Text>
            </TouchableOpacity>
          </View>
        </View>

        {/* 列表演示 */}
        <View style={styles.section}>
          <Text style={styles.sectionTitle}>列表占位符</Text>
          <FlatList
            data={data}
            renderItem={renderListItem}
            keyExtractor={(item) => String(item.id)}
            style={styles.list}
          />
        </View>

        {/* 卡片演示 */}
        <View style={styles.section}>
          <Text style={styles.sectionTitle}>卡片占位符</Text>
          <View style={styles.cardContainer}>
            {renderCardItem()}
            {renderCardItem()}
          </View>
        </View>

        {/* 媒体占位符 */}
        <View style={styles.section}>
          <Text style={styles.sectionTitle}>媒体占位符</Text>
          <Placeholder Animation={getAnimation()} style={styles.mediaPlaceholder}>
            <View style={styles.mediaRow}>
              <PlaceholderMedia size={80} style={styles.mediaLeft} />
              <PlaceholderMedia size={80} style={styles.mediaRight} />
              <PlaceholderMedia size={80} style={styles.mediaCenter} />
            </View>
          </Placeholder>
        </View>

        {/* 功能说明 */}
        <View style={styles.infoSection}>
          <Text style={styles.infoTitle}>功能说明:</Text>
          <Text style={styles.infoText}>• Fade: 淡入淡出动画</Text>
          <Text style={styles.infoText}>• Shine: 闪光动画</Text>
          <Text style={styles.infoText}>• ShineOverlay: 覆盖层闪光动画</Text>
          <Text style={styles.infoText}>• Loader: 加载指示器动画</Text>
          <Text style={styles.infoText}>• Progressive: 渐进式加载动画</Text>
          <Text style={styles.infoText}>• PlaceholderLine: 占位线条</Text>
          <Text style={styles.infoText}>• PlaceholderMedia: 占位媒体</Text>
          <Text style={styles.infoText}>• 支持左右媒体占位</Text>
          <Text style={styles.infoText}>• 完全可自定义样式</Text>
        </View>

        {/* 注意事项 */}
        <View style={styles.noteSection}>
          <Text style={styles.noteTitle}>注意事项:</Text>
          <Text style={styles.noteText}>• 无需原生配置</Text>
          <Text style={styles.noteText}>• 纯 JavaScript 实现</Text>
          <Text style={styles.noteText}>• 支持嵌套布局</Text>
          <Text style={styles.noteText}>• 建议与实际内容布局保持一致</Text>
        </View>
      </ScrollView>
    </SafeAreaView>
  );
}

const styles = StyleSheet.create({
  container: {
    flex: 1,
    backgroundColor: '#f5f5f5',
  },
  content: {
    flex: 1,
  },
  scrollContent: {
    padding: 15,
  },
  title: {
    fontSize: 24,
    fontWeight: 'bold',
    marginBottom: 20,
    textAlign: 'center',
    color: '#333',
  },
  controlPanel: {
    backgroundColor: '#fff',
    borderRadius: 10,
    padding: 15,
    marginBottom: 20,
    shadowColor: '#000',
    shadowOffset: { width: 0, height: 2 },
    shadowOpacity: 0.1,
    shadowRadius: 4,
    elevation: 3,
  },
  controlTitle: {
    fontSize: 16,
    fontWeight: 'bold',
    marginBottom: 12,
    color: '#333',
  },
  animationButtons: {
    flexDirection: 'row',
    flexWrap: 'wrap',
    gap: 8,
    marginBottom: 15,
  },
  animationButton: {
    paddingHorizontal: 12,
    paddingVertical: 8,
    backgroundColor: '#f0f0f0',
    borderRadius: 6,
  },
  activeButton: {
    backgroundColor: '#42a5f5',
  },
  animationButtonText: {
    fontSize: 12,
    color: '#333',
  },
  activeButtonText: {
    color: '#fff',
    fontWeight: 'bold',
  },
  toggleRow: {
    flexDirection: 'row',
    gap: 10,
  },
  toggleButton: {
    flex: 1,
    paddingVertical: 10,
    backgroundColor: '#e3f2fd',
    borderRadius: 8,
    alignItems: 'center',
  },
  toggleButtonText: {
    fontSize: 13,
    color: '#1976d2',
    fontWeight: 'bold',
  },
  section: {
    backgroundColor: '#fff',
    borderRadius: 10,
    padding: 15,
    marginBottom: 20,
    shadowColor: '#000',
    shadowOffset: { width: 0, height: 2 },
    shadowOpacity: 0.1,
    shadowRadius: 4,
    elevation: 3,
  },
  sectionTitle: {
    fontSize: 16,
    fontWeight: 'bold',
    marginBottom: 15,
    color: '#333',
  },
  list: {
    maxHeight: 300,
  },
  listItem: {
    flexDirection: 'row',
    padding: 12,
    borderBottomWidth: 1,
    borderBottomColor: '#f0f0f0',
  },
  listItemPlaceholder: {
    flex: 1,
  },
  listItemContent: {
    flex: 1,
    justifyContent: 'center',
  },
  listItemTitle: {
    fontSize: 16,
    fontWeight: 'bold',
    marginBottom: 4,
    color: '#333',
  },
  listItemSubtitle: {
    fontSize: 14,
    color: '#666',
    marginBottom: 2,
  },
  listItemText: {
    fontSize: 12,
    color: '#999',
  },
  listItemRight: {
    marginLeft: 10,
  },
  listItemImage: {
    width: 60,
    height: 60,
    borderRadius: 8,
    backgroundColor: '#e0e0e0',
  },
  cardContainer: {
    gap: 15,
  },
  card: {
    flexDirection: 'row',
    padding: 15,
    backgroundColor: '#f9f9f9',
    borderRadius: 10,
  },
  cardPlaceholder: {
    flexDirection: 'row',
  },
  cardAvatar: {
    width: 60,
    height: 60,
    borderRadius: 30,
    backgroundColor: '#e0e0e0',
    marginRight: 15,
  },
  cardContent: {
    flex: 1,
    justifyContent: 'center',
  },
  cardTitle: {
    fontSize: 16,
    fontWeight: 'bold',
    marginBottom: 6,
    color: '#333',
  },
  cardText: {
    fontSize: 14,
    color: '#666',
    marginBottom: 2,
  },
  mediaPlaceholder: {
    padding: 15,
  },
  mediaRow: {
    flexDirection: 'row',
    justifyContent: 'space-between',
    alignItems: 'center',
  },
  mediaLeft: {
    backgroundColor: '#e0e0e0',
    borderRadius: 8,
  },
  mediaRight: {
    backgroundColor: '#e0e0e0',
    borderRadius: 8,
  },
  mediaCenter: {
    backgroundColor: '#e0e0e0',
    borderRadius: 8,
  },
  infoSection: {
    padding: 15,
    backgroundColor: '#e3f2fd',
    borderRadius: 8,
    borderLeftWidth: 4,
    borderLeftColor: '#42a5f5',
    marginBottom: 20,
  },
  infoTitle: {
    fontSize: 16,
    fontWeight: 'bold',
    marginBottom: 10,
    color: '#1565c0',
  },
  infoText: {
    fontSize: 14,
    color: '#1976d2',
    marginBottom: 5,
  },
  noteSection: {
    padding: 15,
    backgroundColor: '#fff3cd',
    borderRadius: 8,
    borderLeftWidth: 4,
    borderLeftColor: '#ffc107',
    marginBottom: 20,
  },
  noteTitle: {
    fontSize: 16,
    fontWeight: 'bold',
    marginBottom: 10,
    color: '#856404',
  },
  noteText: {
    fontSize: 14,
    color: '#856404',
    marginBottom: 5,
  },
});

export default PlaceholderDemo;

🎨 实际应用场景

rn-placeholder 可以应用于以下实际场景:

  1. 用户列表: 用户信息加载、联系人列表等
  2. 商品列表: 商品信息加载、购物车列表等
  3. 新闻列表: 新闻标题加载、文章列表等
  4. 图片列表: 图片加载、相册列表等
  5. 社交应用: 动态加载、朋友圈列表等
  6. 电商应用: 商品详情加载、订单列表等
  7. 视频应用: 视频列表加载、播放列表等
  8. 音乐应用: 歌曲列表加载、播放列表等

⚠️ 注意事项与最佳实践

1. 动画选择

// ✅ 推荐:根据场景选择合适的动画
// Fade: 适用于大多数场景,性能好
<Placeholder Animation={Fade} />

// Shine: 适用于需要强调加载的场景
<Placeholder Animation={Shine} />

// ShineOverlay: 适用于白色背景的场景
<Placeholder Animation={ShineOverlay} />

// Loader: 适用于需要明确加载指示的场景
<Placeholder Animation={Loader} />

// Progressive: 适用于需要渐进式显示的场景
<Placeholder Animation={Progressive} />

2. 占位符布局

// ✅ 推荐:占位符布局与实际内容保持一致
<Placeholder Animation={Fade} Left={PlaceholderMedia}>
  <PlaceholderLine width={80} />
  <PlaceholderLine width={60} />
  <PlaceholderLine width={40} />
</Placeholder>

3. 自定义样式

// ✅ 推荐:自定义占位符样式
<PlaceholderLine
  width={80}
  height={15}
  color="#e0e0e0"
  noMargin={false}
  style={{ marginTop: 8 }}
/>

4. 媒体占位符

// ✅ 推荐:使用媒体占位符
<PlaceholderMedia
  size={60}
  isRound={true}
  color="#e0e0e0"
  style={{ borderRadius: 30 }}
/>

5. 左右媒体占位

// ✅ 推荐:使用左右媒体占位
<Placeholder
  Animation={Fade}
  Left={PlaceholderMedia}
  Right={PlaceholderMedia}
>
  <PlaceholderLine width={80} />
  <PlaceholderLine width={60} />
</Placeholder>

6. HarmonyOS 特殊处理

在 HarmonyOS 上,需要注意:

  • 性能: 纯 JavaScript 实现,性能优秀
  • 动画: 所有动画效果完全支持
  • 样式: 样式与实际内容保持一致

7. 最佳实践

// ✅ 推荐:封装骨架屏组件
interface SkeletonProps {
  loading: boolean;
  type?: 'list' | 'card' | 'media';
  animation?: any;
}

const Skeleton: React.FC<SkeletonProps> = ({
  loading,
  type = 'list',
  animation = Fade,
}) => {
  if (!loading) return null;

  if (type === 'list') {
    return (
      <Placeholder Animation={animation} Left={PlaceholderMedia}>
        <PlaceholderLine width={80} />
        <PlaceholderLine width={60} />
        <PlaceholderLine width={40} />
      </Placeholder>
    );
  }

  if (type === 'card') {
    return (
      <Placeholder Animation={animation}>
        <PlaceholderMedia isRound={true} size={60} />
        <PlaceholderLine width={70} />
        <PlaceholderLine width={50} />
      </Placeholder>
    );
  }

  return null;
};

🧪 测试验证

1. Android 平台测试

npm run android

测试要点:

  • 测试不同动画效果
  • 验证占位符布局
  • 测试加载状态切换
  • 检查性能表现

2. iOS 平台测试

npm run ios

测试要点:

  • 测试动画流畅度
  • 验证样式一致性
  • 测试内存使用
  • 检查渲染性能

3. HarmonyOS 平台测试

npm run harmony

测试要点:

  • 验证占位符渲染
  • 测试动画效果
  • 检查性能表现
  • 验证样式一致性

4. 常见问题排查

问题 1: 占位符不显示

  • 检查 loading 状态
  • 确认 Animation 组件正确
  • 验证占位符布局

问题 2: 动画不流畅

  • 减少同时渲染的占位符数量
  • 使用简单的动画(Fade)
  • 优化组件渲染

问题 3: 占位符与实际内容不一致

  • 调整占位符布局
  • 修改占位符尺寸
  • 保持样式一致

📊 API 参考

Placeholder 组件属性

属性类型必填说明
AnimationComponent动画组件
LeftComponent左侧媒体组件
RightComponent右侧媒体组件
styleany自定义样式

PlaceholderLine 组件属性

属性类型必填说明
widthnumber宽度,默认 100
heightnumber高度,默认 12
colorstring颜色,默认 #efefef
noMarginboolean无边距,默认 false
styleany自定义样式

PlaceholderMedia 组件属性

属性类型必填说明
sizenumber尺寸,默认 40
isRoundboolean圆角,默认 false
colorstring颜色,默认 #efefef
styleany自定义样式

动画组件

组件说明
Fade淡入淡出动画
Shine闪光动画
ShineOverlay覆盖层闪光动画
Loader加载指示器动画
Progressive渐进式加载动画

📊 对比:骨架屏方案对比

特性传统加载状态rn-placeholder
视觉体验⚠️ 单调✅ 优雅
布局一致性⚠️ 差异大✅ 完全一致
动画效果⚠️ 简单✅ 多种动画
性能影响⚠️ 中等✅ 轻量级
跨平台一致性⚠️ 一般✅ 完全一致
可定制性⚠️ 有限✅ 高度可定制

📝 总结

通过集成 rn-placeholder,我们为项目添加了优雅的骨架屏加载占位符功能。这个库支持多种动画效果,可以自定义占位符的样式和布局,完全跨平台兼容,无需任何原生配置。

关键要点回顾

  • 安装依赖: npm install rn-placeholder@3.0.3 --legacy-peer-deps
  • 配置平台: 无需原生配置,纯 JavaScript 实现
  • 集成代码: 使用 PlaceholderPlaceholderLinePlaceholderMedia 组件
  • 支持功能: 多种动画、自定义样式、左右媒体占位等
  • 重要: 占位符布局与实际内容保持一致

实际效果

  • Android: 流畅的骨架屏动画
  • iOS: 高质量的占位符效果
  • HarmonyOS: 一致的加载体验
Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐