rn_for_openharmony商城项目app实战-个人中心实现

案例开源地址:https://atomgit.com/nutpi/rn_openharmony_buy

写在前面

请添加图片描述

个人中心这个页面,说白了就是一堆入口的集合。但别小看它,这是用户使用频率很高的页面。用户想看订单、改地址、领优惠券、找客服,都得从这里进去。

设计个人中心的时候,我参考了好几个主流 App。发现大家的布局都差不多:顶部是用户信息,中间是一些数据统计,下面是功能列表。这种布局用户已经很熟悉了,没必要搞创新,老老实实照着来就行。

不过实现起来还是有些细节要注意的,比如数据怎么从全局状态里拿、角标怎么显示、卡片怎么做出层叠效果。这篇文章就来聊聊这些。

先看看要用到哪些数据

个人中心要展示的信息挺多的:用户头像和昵称、收藏数量、浏览历史数量、优惠券数量、各状态订单数量、未读消息数量。这些数据散落在全局状态的各个角落,得一个个拿出来。

import React from 'react';
import {View, Text, StyleSheet, ScrollView, TouchableOpacity, Image} from 'react-native';
import {useApp} from '../store/AppContext';
import {ListItem} from '../components/ListItem';
import {TabBar} from '../components/TabBar';

引入的东西不多。ListItem 是我封装的列表项组件,后面功能列表会用到。

export const ProfileScreen = () => {
  const {user, navigate, orders, favorites, coupons, unreadCount, browseHistory} = useApp();

一口气从 useApp 里解构出一堆东西。这就是全局状态的好处,不用一层层传 props,直接拿就行。

数据都从哪来的?

user 是用户信息,包括头像、昵称、手机号。orders 是订单列表,用来统计各状态订单数量。favoritesbrowseHistory 分别是收藏和浏览历史。coupons 是优惠券列表。unreadCount 是未读消息数,这个在 Context 里已经算好了。

订单状态配置

订单区域要显示四个状态:待付款、待发货、待收货、待评价。我用数组来配置,方便后面遍历渲染:

const orderStats = [
  {label: '待付款', icon: '💳', status: 'pending'},
  {label: '待发货', icon: '📦', status: 'paid'},
  {label: '待收货', icon: '🚚', status: 'shipped'},
  {label: '待评价', icon: '⭐', status: 'delivered'},
];

每个配置项包含显示文字、图标和对应的订单状态值。status 字段用来从订单列表里筛选对应状态的订单。

再写个辅助函数来统计数量:

const getOrderCount = (status: string) => orders.filter(o => o.status === status).length;

传入状态值,返回该状态的订单数量。简单粗暴。

为什么不在 Context 里算好?

也可以,但我觉得这种页面级别的统计放在页面里算更合适。Context 里已经有 unreadCount 了,再加一堆订单统计会显得臃肿。而且这个统计只有个人中心用,没必要全局共享。

用户信息卡片

页面顶部是用户信息区域,蓝色背景,比较醒目:

return (
  <View style={styles.container}>
    <ScrollView style={styles.content}>
      <TouchableOpacity style={styles.userCard} onPress={() => navigate('profileEdit')}>
        <Image source={{uri: user?.avatar}} style={styles.avatar} />

整个卡片用 TouchableOpacity 包裹,点击跳转到个人资料编辑页。头像用 Image 组件,数据源是用户信息里的 avatar 字段。

        <View style={styles.userInfo}>
          <Text style={styles.username}>{user?.username || '未登录'}</Text>
          <Text style={styles.phone}>{user?.phone || '点击登录'}</Text>
        </View>
        <Text style={styles.arrow}>›</Text>
      </TouchableOpacity>

用户名和手机号,如果没登录就显示引导文案。右边一个箭头,暗示用户这里可以点击进入下一级页面。

可选链操作符 ?.

user?.avatar 这种写法是 ES2020 的可选链语法。如果 usernullundefined,不会报错,而是返回 undefined。这样就不用写 user && user.avatar 这种啰嗦的判断了。

数据统计卡片

用户卡片下面是数据统计区,展示收藏、足迹、优惠券三个数字:

      <View style={styles.statsCard}>
        <TouchableOpacity style={styles.statItem} onPress={() => navigate('favorites')}>
          <Text style={styles.statValue}>{favorites.length}</Text>
          <Text style={styles.statLabel}>收藏</Text>
        </TouchableOpacity>

每个统计项都可以点击,跳转到对应的详情页。数字在上,文字在下,这是常见的数据展示方式。

        <View style={styles.statDivider} />
        <TouchableOpacity style={styles.statItem} onPress={() => navigate('browseHistory')}>
          <Text style={styles.statValue}>{browseHistory.length}</Text>
          <Text style={styles.statLabel}>足迹</Text>
        </TouchableOpacity>

中间用分割线隔开。statDivider 就是一条竖线,宽度 1 像素,颜色浅灰。

        <View style={styles.statDivider} />
        <TouchableOpacity style={styles.statItem} onPress={() => navigate('couponList')}>
          <Text style={styles.statValue}>{coupons.filter(c => !c.used).length}</Text>
          <Text style={styles.statLabel}>优惠券</Text>
        </TouchableOpacity>
      </View>

优惠券这里有个小细节:显示的是可用优惠券数量,所以要过滤掉已使用的。filter(c => !c.used) 筛选出 usedfalse 的优惠券。

为什么要做成卡片叠加效果?

你可能注意到了,这个白色卡片会"压"在上面蓝色区域上。这是通过负 margin 实现的,后面样式部分会讲。这种设计能增加页面的层次感,不会显得太平。

订单快捷入口

这是个人中心的重头戏,用户查订单的频率很高:

      <View style={styles.orderCard}>
        <TouchableOpacity style={styles.orderHeader} onPress={() => navigate('orderList')}>
          <Text style={styles.orderTitle}>我的订单</Text>
          <View style={styles.orderAllBtn}>
            <Text style={styles.orderAll}>全部订单</Text>
            <Text style={styles.orderArrow}>›</Text>
          </View>
        </TouchableOpacity>

头部左边是标题,右边是"全部订单"入口。点击跳转到订单列表页。

        <View style={styles.orderStats}>
          {orderStats.map(item => {
            const count = getOrderCount(item.status);
            return (
              <TouchableOpacity 
                key={item.label} 
                style={styles.orderItem} 
                onPress={() => navigate('orderList')}
              >

遍历前面定义的 orderStats 数组,渲染四个订单状态入口。每个入口点击都跳转到订单列表页。

能不能跳转时带上状态参数?

当然可以,比如 navigate('orderList', {status: item.status}),然后订单列表页根据参数筛选显示。我这里偷懒了,都跳到全部订单,用户自己切换状态。正式项目建议加上参数。

                <View style={styles.orderIconWrap}>
                  <Text style={styles.orderIcon}>{item.icon}</Text>
                  {count > 0 && (
                    <View style={styles.orderBadge}>
                      <Text style={styles.orderBadgeText}>{count}</Text>
                    </View>
                  )}
                </View>
                <Text style={styles.orderLabel}>{item.label}</Text>
              </TouchableOpacity>
            );
          })}
        </View>
      </View>

图标右上角的角标只在数量大于 0 时显示。这个细节很重要,如果没有待付款订单,就不应该显示角标,不然用户会困惑。

角标用绝对定位放在图标右上角,红色背景白色文字,很醒目。

功能列表

下面是两组功能入口,用 ListItem 组件来渲染:

      <View style={styles.section}>
        <ListItem 
          icon="❤️" 
          title="我的收藏" 
          rightText={`${favorites.length}件`} 
          onPress={() => navigate('favorites')} 
        />
        <ListItem 
          icon="🕐" 
          title="浏览历史" 
          rightText={`${browseHistory.length}条`} 
          onPress={() => navigate('browseHistory')} 
        />
        <ListItem 
          icon="🎫" 
          title="优惠券" 
          rightText={`${coupons.filter(c => !c.used).length}张可用`} 
          onPress={() => navigate('couponList')} 
        />
        <ListItem 
          icon="📍" 
          title="收货地址" 
          onPress={() => navigate('addressList')} 
        />
      </View>

第一组是和购物相关的功能:收藏、浏览历史、优惠券、收货地址。rightText 属性用来显示右侧的附加信息,比如数量。

ListItem 组件的好处

把列表项抽成组件后,所有列表的样式都是统一的。图标在左边,标题在中间,右边可以显示文字或箭头。如果哪天要改样式,改一个地方就够了。

      <View style={styles.section}>
        <ListItem 
          icon="🔔" 
          title="消息通知" 
          rightText={unreadCount > 0 ? `${unreadCount}条未读` : ''} 
          onPress={() => navigate('messageList')} 
        />
        <ListItem 
          icon="❓" 
          title="帮助中心" 
          onPress={() => navigate('helpCenter')} 
        />
        <ListItem 
          icon="💬" 
          title="意见反馈" 
          onPress={() => navigate('feedback')} 
        />
        <ListItem 
          icon="⚙️" 
          title="设置" 
          onPress={() => navigate('settings')} 
        />
      </View>

第二组是服务类功能:消息、帮助、反馈、设置。消息那里会显示未读数量,没有未读就不显示。

两组之间有个间距,视觉上区分开不同类型的功能。

      </ScrollView>
      <TabBar />
    </View>
  );
};

最后别忘了 TabBar,个人中心是底部导航的四个页面之一。

ListItem 组件长什么样

顺便说一下 ListItem 组件的实现,很简单:

interface Props {
  icon: string;
  title: string;
  rightText?: string;
  onPress?: () => void;
}

export const ListItem = ({icon, title, rightText, onPress}: Props) => (
  <TouchableOpacity style={styles.container} onPress={onPress}>
    <Text style={styles.icon}>{icon}</Text>
    <Text style={styles.title}>{title}</Text>
    <Text style={styles.rightText}>{rightText}</Text>
    <Text style={styles.arrow}>›</Text>
  </TouchableOpacity>
);

接收图标、标题、右侧文字和点击回调。布局是一行四列:图标、标题、右侧文字、箭头。标题用 flex: 1 占据剩余空间。

为什么箭头要单独写?

因为几乎所有列表项都需要箭头,表示可以点击进入下一级。如果某些场景不需要箭头,可以加个 showArrow 属性来控制。

样式详解

先看用户卡片的样式:

const styles = StyleSheet.create({
  container: {flex: 1, backgroundColor: '#f5f5f5'},
  content: {flex: 1},
  userCard: {
    flexDirection: 'row',
    alignItems: 'center',
    backgroundColor: '#3498db',
    paddingTop: 60,
    paddingBottom: 24,
    paddingHorizontal: 20,
  },

蓝色背景,paddingTop: 60 给状态栏留空间。paddingBottom: 24 比较大,是为了给下面的统计卡片留出叠加的空间。

  avatar: {
    width: 70,
    height: 70,
    borderRadius: 35,
    backgroundColor: '#fff',
    borderWidth: 3,
    borderColor: 'rgba(255,255,255,0.3)',
  },

头像是圆形,borderRadius 设为宽高的一半。加了个半透明的白色边框,看起来更精致。

  username: {fontSize: 22, fontWeight: 'bold', color: '#fff'},
  phone: {fontSize: 14, color: 'rgba(255,255,255,0.8)', marginTop: 4},

用户名大一点、加粗,手机号小一点、颜色淡一点,形成主次关系。

统计卡片的关键样式:

  statsCard: {
    flexDirection: 'row',
    backgroundColor: '#fff',
    marginHorizontal: 16,
    marginTop: -16,
    borderRadius: 12,
    paddingVertical: 16,
    shadowColor: '#000',
    shadowOffset: {width: 0, height: 2},
    shadowOpacity: 0.1,
    shadowRadius: 8,
    elevation: 4,
  },

重点是 marginTop: -16,负值让卡片向上偏移,和上面的蓝色区域产生叠加效果。阴影让卡片有悬浮感,elevation 是 Android 的阴影属性。

  statItem: {flex: 1, alignItems: 'center'},
  statValue: {fontSize: 22, fontWeight: 'bold', color: '#333'},
  statLabel: {fontSize: 13, color: '#999', marginTop: 4},
  statDivider: {width: 1, backgroundColor: '#f0f0f0'},

三个统计项用 flex: 1 平分空间,中间用 1 像素的分割线隔开。

订单区域的角标样式:

  orderIconWrap: {
    position: 'relative',
    width: 44,
    height: 44,
    borderRadius: 22,
    backgroundColor: '#f5f5f5',
    justifyContent: 'center',
    alignItems: 'center',
  },
  orderBadge: {
    position: 'absolute',
    top: -4,
    right: -4,
    backgroundColor: '#e74c3c',
    borderRadius: 10,
    minWidth: 18,
    height: 18,
    justifyContent: 'center',
    alignItems: 'center',
  },
  orderBadgeText: {color: '#fff', fontSize: 11, fontWeight: 'bold'},

图标外面套一个圆形容器,角标用绝对定位放在右上角。minWidth: 18 保证角标至少有一定宽度,数字多的时候会自动撑开。

  section: {backgroundColor: '#fff', marginTop: 12},
});

功能列表的分组,白色背景,上面留 12 像素的间距和上一个区块分开。

一些细节优化

当前实现已经能用了,但还有一些可以打磨的地方:

1. 登录状态判断

现在不管有没有登录都显示同样的内容。正式项目应该判断登录状态,未登录时显示登录引导,点击跳转登录页而不是编辑资料页。

2. 下拉刷新

用户可能想刷新数据,比如看看有没有新消息、订单状态有没有变化。可以给 ScrollView 加上 RefreshControl 组件实现下拉刷新。

3. 骨架屏

如果数据是从接口拿的,加载过程中可以显示骨架屏,比直接显示空白或 loading 图标体验更好。

什么时候做这些优化?

看项目阶段。如果是快速验证想法的 MVP,先把主流程跑通。如果是要上线的产品,这些细节都要考虑。用户体验是由无数个细节堆出来的。

写在最后

个人中心看起来就是一堆入口的堆砌,但要做好也不容易。信息的层次要清晰,重要的放上面,次要的放下面。数据要实时,用户一眼就能看到自己有多少收藏、多少未读消息。交互要顺畅,点哪里跳哪里,不能让用户迷路。

这个页面涉及到的全局状态比较多,也是对 Context 设计的一个检验。如果发现某个数据拿起来很别扭,可能是 Context 的结构需要调整。

下一篇写商品详情页,那个页面交互会更复杂一些,涉及到加购物车、收藏、查看评价等功能。


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

Logo

开源鸿蒙跨平台开发社区汇聚开发者与厂商,共建“一次开发,多端部署”的开源生态,致力于降低跨端开发门槛,推动万物智联创新。

更多推荐