在这里插入图片描述

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

装备列表页已经有了分类筛选,但有时候用户想要更精细的筛选条件,比如"我只想看 1000 金币以下的攻击装备"。装备筛选页提供分类和价格两个维度的筛选,让用户能更精准地找到目标装备。

这篇文章我们来实现装备筛选页,重点是多维度筛选的组合逻辑、价格区间的设计思路、以及筛选按钮的交互状态管理。

筛选维度的设计思考

在设计筛选功能之前,我们需要思考用户真正需要什么样的筛选条件。

分类筛选

装备的分类是最直观的筛选维度。英雄联盟的装备按属性分成多个类别:

  • 攻击类:提供攻击力、暴击等属性
  • 防御类:提供护甲、魔抗、生命值等属性
  • 法术类:提供法术强度、法力值等属性
  • 攻速类:提供攻击速度
  • 移速类:提供移动速度
  • 吸血类:提供生命偷取

玩家通常会根据自己的英雄定位来选择装备类型。比如 AD 刺客会关注攻击类装备,坦克会关注防御类装备。

价格筛选

价格是装备筛选特有的维度,这个维度在英雄筛选中是没有的。为什么价格对装备筛选很重要?

游戏节奏的影响:英雄联盟的对局分为前期、中期、后期。前期金币少,只能买便宜的基础装备;中期有一定经济,可以合成中等价位的装备;后期经济充裕,才能买得起昂贵的成型装备。

出装顺序的规划:玩家在规划出装路线时,需要知道每件装备的价格,才能合理安排购买顺序。

基于这些考虑,我们把价格分成三档:

const priceRanges = [
  {key: 'All', label: '全部', min: 0, max: 99999},
  {key: 'Low', label: '< 1000', min: 0, max: 999},
  {key: 'Medium', label: '1000-2500', min: 1000, max: 2500},
  {key: 'High', label: '> 2500', min: 2501, max: 99999},
];

这个分档是根据游戏经验设定的:

  • 1000 以下:基础装备和小件,对线期就能买,比如长剑(350)、布甲(300)
  • 1000-2500:中等装备,中期过渡用,比如塞瑞尔达的怨恨(2600 刚好超出,但很多中等装备在这个区间)
  • 2500 以上:成型装备,后期核心,比如无尽之刃(3400)、兰顿之兆(2700)

页面结构与状态管理

import React, {useState, useMemo} from 'react';
import {View, Text, TouchableOpacity, StyleSheet} from 'react-native';
import {colors} from '../../styles/colors';
import {useApp} from '../../context/AppContext';
import {useNavigation} from '../../context/NavigationContext';
import {itemApi} from '../../api';
import {ItemGrid} from '../../components/item';
import type {Item} from '../../models/Item';

这个页面需要的依赖比较简单,主要是状态管理相关的 Hook 和 UI 组件。

export function ItemFilterPage() {
  const {state} = useApp();
  const {navigate} = useNavigation();
  const [selectedTag, setSelectedTag] = useState('All');
  const [selectedPrice, setSelectedPrice] = useState('All');

  const categories = itemApi.getItemCategories();

状态设计说明

  • selectedTag:当前选中的分类,默认 ‘All’ 表示不限制分类
  • selectedPrice:当前选中的价格区间,默认 ‘All’ 表示不限制价格

两个状态都默认为 ‘All’,这样用户进入页面时会看到所有装备,然后根据需要逐步缩小范围。这种"从全量到精确"的筛选体验比较符合用户习惯。

categories 是分类选项列表,从 API 模块获取。把这个数据放在 API 模块而不是硬编码在页面中,是为了方便统一管理和后续修改。

筛选逻辑的实现

筛选逻辑是这个页面的核心,我们用 useMemo 来优化性能:

  const filteredItems = useMemo(() => {
    let result = state.items;

    // 按分类筛选
    if (selectedTag !== 'All') {
      result = itemApi.filterItemsByTag(result, selectedTag);
    }

    // 按价格筛选
    if (selectedPrice !== 'All') {
      const priceRange = priceRanges.find(p => p.key === selectedPrice);
      if (priceRange) {
        result = result.filter(
          item =>
            (item.gold?.total || 0) >= priceRange.min &&
            (item.gold?.total || 0) <= priceRange.max,
        );
      }
    }

    return result;
  }, [state.items, selectedTag, selectedPrice]);

链式筛选的工作原理

  1. 从全量数据 state.items 开始
  2. 如果选择了分类,先按分类过滤
  3. 在分类过滤的结果上,再按价格过滤
  4. 返回最终结果

这种链式筛选意味着两个条件是**"与"的关系**——装备必须同时满足分类条件和价格条件才会显示。

为什么用 useMemo?

装备列表有 200+ 条数据,每次筛选都要遍历整个数组。如果不用 useMemo,每次组件渲染都会重新计算,即使筛选条件没有变化。useMemo 会缓存计算结果,只有当依赖项(state.itemsselectedTagselectedPrice)变化时才重新计算。

价格筛选的细节处理

(item.gold?.total || 0) >= priceRange.min

这里用了可选链 ?. 和空值合并 || 0。有些装备的 gold 字段可能为空(比如一些特殊装备),这样处理可以避免报错,同时把没有价格的装备当作 0 金币处理。

分类筛选区域的渲染

  const FilterHeader = (
    <View style={styles.filterContainer}>
      {/* 分类筛选 */}
      <View style={styles.filterSection}>
        <Text style={styles.filterTitle}>分类</Text>
        <View style={styles.filterOptions}>
          {categories.map(option => (
            <TouchableOpacity
              key={option.key}
              style={[
                styles.filterBtn,
                selectedTag === option.key && styles.filterBtnActive,
              ]}
              onPress={() => setSelectedTag(option.key)}>
              <Text
                style={[
                  styles.filterBtnText,
                  selectedTag === option.key && styles.filterBtnTextActive,
                ]}>
                {option.label}
              </Text>
            </TouchableOpacity>
          ))}
        </View>
      </View>

条件样式的实现技巧

style={[styles.filterBtn, selectedTag === option.key && styles.filterBtnActive]}

这是 React Native 中实现条件样式的常用模式。style 属性接受一个数组,数组中的样式会按顺序合并。当 selectedTag === option.key 为 true 时,filterBtnActive 的样式会覆盖 filterBtn 中的同名属性。

遍历渲染的好处

categories.map() 遍历渲染按钮,而不是手写每个按钮,有几个好处:

  1. 代码简洁:不需要重复写 7 个几乎一样的按钮
  2. 易于维护:要加新分类只需要改 categories 数组
  3. 一致性:所有按钮的结构和行为完全一致

价格筛选区域的渲染

      {/* 价格筛选 */}
      <View style={styles.filterSection}>
        <Text style={styles.filterTitle}>价格</Text>
        <View style={styles.filterOptions}>
          {priceRanges.map(option => (
            <TouchableOpacity
              key={option.key}
              style={[
                styles.filterBtn,
                selectedPrice === option.key && styles.filterBtnActive,
              ]}
              onPress={() => setSelectedPrice(option.key)}>
              <Text
                style={[
                  styles.filterBtnText,
                  selectedPrice === option.key && styles.filterBtnTextActive,
                ]}>
                {option.label}
              </Text>
            </TouchableOpacity>
          ))}
        </View>
      </View>

价格筛选的渲染逻辑和分类筛选完全一样,只是数据源和状态变量不同。这种结构上的一致性让代码更容易理解和维护。

结果统计的展示

      {/* 结果统计 */}
      <View style={styles.resultInfo}>
        <Text style={styles.resultText}>
          找到 {filteredItems.length} 件装备
        </Text>
      </View>
    </View>
  );

结果统计是一个很重要的反馈信息,它告诉用户:

  • 筛选是否生效:如果数量变少了,说明筛选条件起作用了
  • 是否需要调整:如果显示"找到 0 件装备",用户就知道条件太严格了
  • 当前范围大小:帮助用户判断是否需要进一步筛选

页面整体渲染

  const handleItemPress = (item: Item) => {
    navigate('ItemDetail', {itemId: item.id});
  };

  return (
    <View style={styles.container}>
      <ItemGrid
        items={filteredItems}
        version={state.version}
        onItemPress={handleItemPress}
        ListHeaderComponent={FilterHeader}
      />
    </View>
  );
}

复用 ItemGrid 组件展示筛选结果。ListHeaderComponent 属性让筛选区域显示在列表顶部,并且会随列表一起滚动。

样式设计详解

筛选按钮的样式

const styles = StyleSheet.create({
  container: {
    flex: 1,
    backgroundColor: colors.background,
  },
  filterContainer: {
    marginBottom: 8,
  },
  filterSection: {
    marginBottom: 16,
  },
  filterTitle: {
    fontSize: 14,
    fontWeight: '600',
    color: colors.textPrimary,
    marginBottom: 8,
  },
  filterOptions: {
    flexDirection: 'row',
    flexWrap: 'wrap',
    marginHorizontal: -4,
  },

filterOptions 的布局技巧

  • flexDirection: 'row':让按钮横向排列
  • flexWrap: 'wrap':允许换行,当一行放不下时自动换到下一行
  • marginHorizontal: -4:抵消按钮的外边距,让第一个按钮和最后一个按钮与容器边缘对齐

按钮的两种状态

  filterBtn: {
    paddingHorizontal: 12,
    paddingVertical: 6,
    borderRadius: 16,
    backgroundColor: colors.backgroundCard,
    margin: 4,
    borderWidth: 1,
    borderColor: colors.border,
  },
  filterBtnActive: {
    backgroundColor: colors.primary,
    borderColor: colors.primary,
  },
  filterBtnText: {
    fontSize: 13,
    color: colors.textSecondary,
  },
  filterBtnTextActive: {
    color: colors.background,
    fontWeight: '600',
  },

未选中状态:灰色背景、灰色边框、灰色文字,视觉上比较低调

选中状态:金色背景、金色边框、深色文字并加粗,非常醒目

这种对比让用户一眼就能看出哪些条件被选中了。borderRadius: 16 配合较小的高度形成"胶囊"形状,这是移动端常见的按钮设计。

结果统计的样式

  resultInfo: {
    paddingVertical: 8,
    borderTopWidth: 1,
    borderTopColor: colors.border,
  },
  resultText: {
    fontSize: 14,
    color: colors.textSecondary,
  },
});

结果统计区域上方有一条分隔线,把它和筛选按钮在视觉上分开。文字用次要颜色,不抢筛选按钮的风头。

交互优化建议

当前实现已经能满足基本需求,但还有一些可以优化的地方:

重置按钮:当用户选了多个筛选条件后,可能想一键清除所有条件。可以在结果统计旁边加一个"重置"按钮:

<TouchableOpacity onPress={() => {
  setSelectedTag('All');
  setSelectedPrice('All');
}}>
  <Text style={styles.resetText}>重置</Text>
</TouchableOpacity>

筛选条件的持久化:用户离开页面再回来,筛选条件会重置。如果想保持用户的选择,可以用 AsyncStorage 存储。

排序功能:在筛选的基础上加排序功能,比如按价格从低到高、从高到低排序。

空结果的友好提示:当筛选结果为空时,显示一个更友好的提示,引导用户放宽条件。

小结

装备筛选页展示了多维度筛选的实现方法。核心要点:

  1. 状态分离:每个筛选维度用独立的状态管理
  2. 链式筛选:多个条件依次过滤,形成"与"的关系
  3. useMemo 优化:缓存筛选结果,避免不必要的重复计算
  4. 条件样式:用数组合并样式,实现选中/未选中的视觉区分

下一篇我们来实现装备搜索功能,通过输入关键词快速找到目标装备。


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

Logo

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

更多推荐