在这里插入图片描述

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

出装的时候经常会纠结:同样是攻击装,无尽之刃和饮血剑哪个更适合当前局势?同样是防御装,兰顿之兆和荆棘之甲哪个性价比更高?装备对比功能让用户可以选择两件装备,把它们的价格、属性并排展示,帮助做出更好的出装决策。

这篇文章我们从产品设计的角度出发,分析装备对比的使用场景,然后一步步实现这个功能。重点包括弹窗选择器的复用设计、对比数据的展示布局、以及如何处理装备属性的差异化显示。

使用场景分析

在开始写代码之前,先想想用户会在什么情况下使用装备对比功能:

场景一:同类装备的选择

用户想出一件攻击装,但不确定选哪个。比如无尽之刃和纳什之牙都能提升输出,但一个偏物理暴击,一个偏攻速法伤。通过对比可以看出两者的属性差异。

场景二:性价比的考量

有时候两件装备效果类似,但价格差很多。比如用户想要护甲,布甲只要 300 金币,锁子甲要 800 金币。对比可以帮助用户判断多花的钱是否值得。

场景三:合成路线的规划

用户在规划出装路线时,可能想比较两条不同的合成路线。比如先出暴风大剑还是先出斗篷,哪个更划算。

理解这些场景后,我们知道对比功能需要展示的核心信息是:价格属性加成

整体架构设计

装备对比页的交互流程:

┌─────────────────────────────────────┐
│  ┌─────────┐   VS   ┌─────────┐    │
│  │ 选择框1  │        │ 选择框2  │    │
│  │  点击   │        │  点击   │    │
│  └────┬────┘        └────┬────┘    │
│       │                  │         │
│       ▼                  ▼         │
│  ┌─────────────────────────────┐   │
│  │      弹窗:选择装备          │   │
│  │   (复用同一个弹窗组件)      │   │
│  └─────────────────────────────┘   │
│                                    │
│  ┌─────────────────────────────┐   │
│  │      对比结果区域            │   │
│  │   (两件都选好后显示)        │   │
│  └─────────────────────────────┘   │
└─────────────────────────────────────┘

关键设计决策:

  1. 复用弹窗:两个选择框共用一个弹窗组件,通过状态区分是为哪个位置选装备
  2. 条件渲染:只有两件装备都选好后才显示对比结果
  3. 数据来源:装备数据从全局状态获取,不需要额外请求

导入依赖与类型定义

import React, {useState} from 'react';
import {
  View,
  Text,
  ScrollView,
  Image,
  TouchableOpacity,
  StyleSheet,
} from 'react-native';
import {colors} from '../../styles/colors';
import {useApp} from '../../context/AppContext';
import {getItemIconUrl} from '../../utils/image';
import {formatNumber} from '../../utils/format';
import {Modal} from '../../components/common';
import type {Item} from '../../models/Item';

依赖说明

  • Modal:自己封装的弹窗组件,用于显示装备选择列表
  • formatNumber:数字格式化函数,给价格加上千分位
  • getItemIconUrl:生成装备图标 URL 的工具函数
  • Item:装备的类型定义

这些都是项目中已有的模块,体现了代码复用的思想。

状态设计

export function ItemComparePage() {
  const {state} = useApp();
  const [item1, setItem1] = useState<Item | null>(null);
  const [item2, setItem2] = useState<Item | null>(null);
  const [showPicker, setShowPicker] = useState<1 | 2 | null>(null);

这个页面有三个状态,每个都有明确的职责:

item1 和 item2

存储用户选择的两件装备。类型是 Item | null,null 表示还没选择。

为什么用 null 而不是 undefined?这是一个编码习惯的问题。null 表示"明确没有值",undefined 表示"值未定义"。在这个场景下,初始状态是"用户还没选择",用 null 语义更准确。

showPicker

控制弹窗的显示状态。类型是 1 | 2 | null,这是 TypeScript 的字面量联合类型:

  • 1:正在为左边的位置选装备
  • 2:正在为右边的位置选装备
  • null:弹窗关闭

为什么不用 boolean?因为我们不仅要知道弹窗是否打开,还要知道是为哪个位置选装备。用字面量类型比用两个 boolean(showPicker + pickerTarget)更简洁。

选择装备的处理逻辑

  const handleSelectItem = (item: Item) => {
    if (showPicker === 1) setItem1(item);
    else if (showPicker === 2) setItem2(item);
    setShowPicker(null);
  };

当用户在弹窗中点击一件装备时:

  1. 根据 showPicker 的值判断是更新 item1 还是 item2
  2. 关闭弹窗(setShowPicker(null)

这个函数很简单,但它是"一个弹窗服务两个选择框"设计的关键。如果用两个独立的弹窗,就需要两个类似的处理函数,代码会重复。

选择区域的渲染

  return (
    <ScrollView style={styles.container} showsVerticalScrollIndicator={false}>
      <View style={styles.selectArea}>
        <TouchableOpacity style={styles.selectBox} onPress={() => setShowPicker(1)}>
          {item1 ? (
            <>
              <Image source={{uri: getItemIconUrl(state.version, item1.id)}} style={styles.itemIcon} />
              <Text style={styles.itemName}>{item1.name}</Text>
            </>
          ) : (
            <Text style={styles.selectText}>选择装备</Text>
          )}
        </TouchableOpacity>
        <Text style={styles.vsText}>VS</Text>
        <TouchableOpacity style={styles.selectBox} onPress={() => setShowPicker(2)}>
          {item2 ? (
            <>
              <Image source={{uri: getItemIconUrl(state.version, item2.id)}} style={styles.itemIcon} />
              <Text style={styles.itemName}>{item2.name}</Text>
            </>
          ) : (
            <Text style={styles.selectText}>选择装备</Text>
          )}
        </TouchableOpacity>
      </View>

选择框的两种状态

每个选择框根据是否已选择装备,显示不同的内容:

  • 未选择:显示灰色的"选择装备"提示文字
  • 已选择:显示装备图标和名称

这种条件渲染用三元表达式实现:{item1 ? (...已选择的内容...) : (...未选择的内容...)}

Fragment 的使用

<></> 是 React Fragment 的简写。当需要返回多个元素但不想增加额外的 DOM 节点时使用。这里用它包裹图标和名称,因为 TouchableOpacity 的 children 需要是单个元素或数组。

VS 文字的设计

中间的"VS"用金色大字体,增加对抗感和视觉冲击力。这是一个纯装饰性的元素,但能让页面更有游戏感。

对比结果的渲染

      {item1 && item2 && (
        <View style={styles.compareCard}>
          <View style={styles.compareRow}>
            <Text style={styles.value}>{formatNumber(item1.gold?.total || 0)}</Text>
            <Text style={styles.label}>价格</Text>
            <Text style={styles.value}>{formatNumber(item2.gold?.total || 0)}</Text>
          </View>
        </View>
      )}

条件渲染的时机

{item1 && item2 && (...)} 确保只有两件装备都选好后才显示对比结果。这是短路求值的应用:

  • 如果 item1 是 null,整个表达式返回 null(不渲染任何内容)
  • 如果 item1 有值但 item2 是 null,整个表达式返回 null
  • 只有两个都有值时,才渲染后面的 JSX

对比行的布局

每一行分三部分:左边的值、中间的标签、右边的值。用 justifyContent: 'space-between' 让它们均匀分布。

formatNumber 的作用

formatNumber(3400) 会返回 "3,400",给数字加上千分位分隔符,更易读。

装备选择弹窗

      <Modal visible={showPicker !== null} onClose={() => setShowPicker(null)} title="选择装备">
        <ScrollView style={styles.pickerList}>
          {state.items.slice(0, 50).map(item => (
            <TouchableOpacity key={item.id} style={styles.pickerItem} onPress={() => handleSelectItem(item)}>
              <Image source={{uri: getItemIconUrl(state.version, item.id)}} style={styles.pickerIcon} />
              <Text style={styles.pickerName}>{item.name}</Text>
            </TouchableOpacity>
          ))}
        </ScrollView>
      </Modal>
    </ScrollView>
  );
}

Modal 组件的属性

  • visible:控制弹窗是否显示,showPicker !== null 时显示
  • onClose:关闭弹窗的回调,设置 showPicker 为 null
  • title:弹窗标题

装备列表的渲染

state.items.slice(0, 50) 只显示前 50 件装备。为什么要限制数量?

  1. 性能考虑:200+ 件装备全部渲染会有性能问题
  2. 用户体验:列表太长用户很难找到想要的装备

更好的做法是加一个搜索框,让用户可以搜索装备。这个优化留给读者作为练习。

列表项的结构

每个列表项包含装备图标和名称,横向排列。点击后调用 handleSelectItem,传入被点击的装备。

样式设计详解

选择区域样式

const styles = StyleSheet.create({
  container: {flex: 1, backgroundColor: colors.background, padding: 16},
  selectArea: {flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', marginBottom: 24},
  selectBox: {flex: 1, backgroundColor: colors.backgroundCard, borderRadius: 12, padding: 20, alignItems: 'center', borderWidth: 1, borderColor: colors.border},
  selectText: {fontSize: 14, color: colors.textMuted},
  itemIcon: {width: 48, height: 48, borderRadius: 8, marginBottom: 8},
  itemName: {fontSize: 12, color: colors.textPrimary, textAlign: 'center'},
  vsText: {fontSize: 20, fontWeight: 'bold', color: colors.textGold, marginHorizontal: 12},

selectArea 的布局

  • flexDirection: 'row':三个元素(左选择框、VS、右选择框)横向排列
  • justifyContent: 'space-between':两端对齐,VS 在中间
  • alignItems: 'center':垂直居中对齐

selectBox 的设计

  • flex: 1:两个选择框平分剩余空间(VS 文字有固定的 marginHorizontal)
  • borderRadius: 12:圆角让卡片更柔和
  • alignItems: 'center':内容居中显示

对比结果样式

  compareCard: {backgroundColor: colors.backgroundCard, borderRadius: 8, padding: 16, borderWidth: 1, borderColor: colors.border},
  compareRow: {flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', paddingVertical: 8},
  label: {fontSize: 14, color: colors.textSecondary},
  value: {fontSize: 14, color: colors.textGold, fontWeight: '600', width: 80, textAlign: 'center'},

value 的固定宽度

width: 80 让左右两边的数值区域宽度一致,即使数字长度不同也能对齐。textAlign: 'center' 让数字在区域内居中。

弹窗列表样式

  pickerList: {maxHeight: 400},
  pickerItem: {flexDirection: 'row', alignItems: 'center', paddingVertical: 10, borderBottomWidth: 1, borderBottomColor: colors.border},
  pickerIcon: {width: 32, height: 32, borderRadius: 4, marginRight: 12},
  pickerName: {fontSize: 14, color: colors.textPrimary},
});

maxHeight 的作用

限制列表最大高度为 400px,避免弹窗占满整个屏幕。超出部分可以滚动查看。

分隔线的设计

每个列表项底部有 1px 的分隔线,帮助用户区分不同的装备。最后一项也有分隔线,这是一个小瑕疵,可以用 :last-child 伪类去掉(但 React Native 不支持,需要用条件判断)。

功能扩展建议

当前实现是基础版本,还有很多可以扩展的地方:

属性对比

除了价格,还可以对比装备的属性加成:

// 获取两件装备的所有属性键
const allStatKeys = new Set([
  ...Object.keys(item1.stats || {}),
  ...Object.keys(item2.stats || {}),
]);

// 渲染每个属性的对比
{Array.from(allStatKeys).map(key => (
  <View key={key} style={styles.compareRow}>
    <Text style={styles.value}>{item1.stats?.[key] || '-'}</Text>
    <Text style={styles.label}>{formatStatKey(key)}</Text>
    <Text style={styles.value}>{item2.stats?.[key] || '-'}</Text>
  </View>
))}

胜负高亮

像英雄对比页一样,数值高的一方用金色高亮:

const winner = value1 > value2 ? 1 : value1 < value2 ? 2 : 0;

<Text style={[styles.value, winner === 1 && styles.valueWinner]}>
  {value1}
</Text>

搜索功能

在弹窗中加一个搜索框,让用户可以快速找到想要的装备:

<Modal ...>
  <SearchBar value={searchText} onChangeText={setSearchText} />
  <ScrollView>
    {filteredItems.map(item => ...)}
  </ScrollView>
</Modal>

交换位置

加一个按钮让用户可以交换左右两件装备的位置:

<TouchableOpacity onPress={() => {
  const temp = item1;
  setItem1(item2);
  setItem2(temp);
}}>
  <Text>⇄ 交换</Text>
</TouchableOpacity>

小结

装备对比页展示了如何用一个弹窗服务多个选择框的设计模式。核心要点:

  1. 状态设计:用字面量联合类型(1 | 2 | null)区分弹窗的服务对象
  2. 条件渲染:根据状态显示不同的内容(已选择/未选择、显示对比/隐藏对比)
  3. 组件复用:复用 Modal 组件,避免重复代码
  4. 布局技巧:用 Flexbox 实现左-中-右的对称布局

下一篇我们来实现符文系统,展示游戏中的符文列表和详情。


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

Logo

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

更多推荐