ReactNative 实现侧滑删除列表

ReactNative实现左滑删除列表

背景:

需求中要开发一个可以侧滑删除的列表,发现ReactNative并未供这种组件。ReactNative本身其实封装了具有侧滑功能的FlatList在以下目录,但已经被弃用了,研究了一下没有搞懂

./node_modules/react-native/Libraries/Experimental/SwipeableRow/SwipeableFlatList

之后尝试寻找一些第三方库,一个比较好用的是react-native-swipe-list-view,可以实现组件左右滑动、控制滑动的距离、删除动画以及预置的动画演示,符合要求的话建议用这个
react-native-swipe-list-view GitHub地址
react-native-swipe-list-view

但发现这种滑动实际上是两个组件,一个组件放在下面固定,上面的组件滑动,把下面隐藏的内容露出了,并且不能控制最大滑动的距离,只能禁用一侧的滑动,如果滑动超过了下面隐藏组件的范围,就会露出空白,虽然手势释放会回到设置的openValue,但这两点不符合UI的要求,只能开始研究自己实现.

实现效果

SwipeFlatList实现效果

代码

GitHub地址

SwipeRow

  • 模仿react-native-swipe-list-view,实现一个可以滑动的SwipeRow,用PanResponder手势处理以及Animated库来实现滑动、删除的动画
  • 实际上就是一个超过屏幕宽度的view,将隐藏元素放在屏幕外的部分,因为需求只用到左滑,实现的比较简单,利用view的高度绑定AnimatedValue,当删除行时就让view的高度变为0,达到视觉上的删除效果
  • 行的高度和隐藏组件的宽带必须传入来实现动画,如果行高或隐藏内容的宽度是不确定的,无法使用该组件
  • 如果点击隐藏内容后需要删除该行,可以通过ref调用delete功能,并在delete回调中移除列表中的数据,若在动画前就删除数据,不会触发动画,因为列表重新渲染了(可以在Example中看到示例)
import React, { PureComponent } from 'react';
import {
  Animated,
  PanResponder,
  TouchableOpacity,
  ViewStyle,
  GestureResponderEvent,
  PanResponderGestureState,
} from 'react-native';
import { WINDOW_WIDTH } from '../util';

type Props = {
  rightContent?: JSX.Element; //右侧屏幕外组件
  rightContanierStyle?: ViewStyle;
  onRightPress?: () => void; //右侧组件点击事件,若要关闭/删除行,可通过ref调用closwRow/deleteRow方法
  rightContentWidth: number; //右侧组件宽度,即最大左滑距离
  lineHeight: number; //行高,用来设置删除行动画
  onTouchStart?: () => void; //当行响应触摸事件时触发,可用来处理其他SwipeRow
  directionalDistanceChangeThreshold?: number; //接管事件的横向滑动距离
  //setScrollEnabled: (isEnabled: boolean) => void;
};

type State = {};

export class SwipeRow extends PureComponent<Props, State> {
  lineHeigh: Animated.Value;

  directionalDistanceChangeThreshold: number;

  constructor(props: any) {
    super(props);

    this.rightContentWidth = this.props.rightContentWidth || 0;
    this.lineHeigh = new Animated.Value(this.props.lineHeight);
    this.directionalDistanceChangeThreshold =
      this.props.directionalDistanceChangeThreshold || 2;
  }

  closeRow = () => {
    this._startAnimated(0);
  };

  deleteRow = (callbackFn?: () => void) => {
    Animated.timing(this.lineHeigh, {
      toValue: 0,
      duration: 600,
      useNativeDriver: false,
    }).start(callbackFn);
  };

  // x偏移
  pan = new Animated.Value(0);

  rightContentWidth = 0;

  //滑动超过指定距离时接管事件
  _onMoveShouldSet = (
    e: GestureResponderEvent,
    gestureState: PanResponderGestureState,
  ) => {
    const { dx } = gestureState;
    return Math.abs(dx) > this.directionalDistanceChangeThreshold;
  };

  //接管事件后触发函数
  _onPanResponderGrant = () => {
    this.props.onTouchStart && this.props.onTouchStart();
  };

  //滑动
  _onPanResponderMove = (
    e: GestureResponderEvent,
    gestureState: PanResponderGestureState,
  ) => {
    const { dx, dy } = gestureState;
    const absDx = Math.abs(dx);
    const absDy = Math.abs(dy);

    if (
      absDx > this.directionalDistanceChangeThreshold ||
      absDy > this.directionalDistanceChangeThreshold
    ) {
      if (absDy > absDx) {
        return;
      } // moving vertically

      //左滑,直接关闭行
      if (dx > 0) {
        this._startAnimated(0);
      }
      //右滑,最大距离为隐藏内容宽度
      else {
        if (dx < -this.rightContentWidth) {
          this._startAnimated(-this.rightContentWidth);
        } else {
          this._startAnimated(dx);
        }
      }
    }
  };

  //手势释放
  _onPanResponderRelease = (e: GestureResponderEvent, gestureState: PanResponderGestureState,) => {
    const { vx, dx } = gestureState;
    //最后有向左的趋势,直接打开.否则关闭
    if (vx < 0.2 && dx < 0) {
      this._startAnimated(-this.rightContentWidth);
    } else {
      this._startAnimated(0);
    }
  };

  //手势处理
  panResponder = PanResponder.create({
    onMoveShouldSetPanResponder: this._onMoveShouldSet,
    onPanResponderGrant: this._onPanResponderGrant,
    onPanResponderMove: this._onPanResponderMove,
    onPanResponderRelease: this._onPanResponderRelease,
  });

  /**
   * @description: 执行动画修改 pan 的值
   * @param {number} num
   */
  _startAnimated = (num: number) => {
    Animated.spring(this.pan, {
      toValue: num,
      useNativeDriver: false,
    }).start();
  };

  _onRightPress = () => {
    this.props.onRightPress && this.props.onRightPress();
  };

  render() {
    return (
      <Animated.View
        style={[
          {
            flexDirection: 'row',
            width: WINDOW_WIDTH + this.rightContentWidth,
            height: this.lineHeigh,
            transform: [{ translateX: this.pan }],
          },
        ]}
        {...this.panResponder.panHandlers}>
        {this.props.children}
        <TouchableOpacity
          style={
            this.props.rightContanierStyle ? this.props.rightContanierStyle : {}
          }
          onPress={this._onRightPress}>
          {this.props.rightContent || null}
        </TouchableOpacity>
      </Animated.View>
    );
  }
}

SwipeFlatList

  • 封装SwipeRow到FlatList中,除了需要额外传入隐藏元素宽度和行高,可以像用FlatList一样使用该列表
  • 通过ref管理列表中的行,当有行打开时滑动了其他行或者滑动了列表,打开的行会自动关闭
  • 传入getItemlayout,虽然props中该项是可选参数,但不传入也许会导致错误,因为组件需行的宽高已知,getItaemLayout应该是很简单的,只需要按行数来计算就好了,在下面的Example中可以看到例子,同时通过getItemLayout,避免了FlatList动态测量组件,可以提升列表的性能
  • 如果点击隐藏内容会需要进行处理,可以传入onHiddenAreaPress方法,该方法会返回该行的数据以及ref,通过item你可以知道点击行的数据是什么,通过ref你可以控制该行关闭/删除
import React, { Component } from 'react';
import { FlatList } from 'react-native';
import { SwipeRow } from './SwipeRow';
import { View } from 'react-native';
import { WINDOW_WIDTH } from '../util';
import { Text, StyleSheet } from 'react-native'


interface Props<T> {
  data: T[];
  renderItem: (items: { item: T; index: number }) => JSX.Element;
  renderHiddenItem: (items: { item: T; index: number }) => JSX.Element;
  hiddenItemWidth: number;
  ListFooterComponent?: JSX.Element | null;
  ListHeaderComponent?: JSX.Element | null;
  ListEmptyComponent?: JSX.Element | null;
  keyExtrator: (item: T, index: number) => string;
  onHiddenAreaPress: (item: T, clickRow: SwipeRow | undefined) => void;
  lineHeight: number;
  onLayout?: () => void;
  getItemLayout?: (
    data: Array<T> | null | undefined,
    index: number,
  ) => { length: number; offset: number; index: number };
}

/**
 * 侧滑删除列表
 * @param renderItem 行元素,若包括点击事件,将有onPress的组件放在最外层
 * @param renderHiddenItem 隐藏元素,在renderItem的右侧
 * @param hiddenItemWidth 隐藏元素宽度,即该行最大可左滑的距离
 * @param onHiddenAreaPress 隐藏元素点击事件,将返回参数 item、clickrow 可通过clickRow调用SwipeRow的关闭、删除方法
 * @param lineHeight 行高,用于删除行动画
 */
export class SwipeFlatList<T> extends Component<Props<T>> {
  rowMap: Map<T, SwipeRow>;

  currentRow: SwipeRow | undefined;

  constructor(props: Props<T>) {
    super(props);

    this.rowMap = new Map();
    this.currentRow = undefined;
  }

  onGetRowRef = (item: T, ref: SwipeRow | null) => {
    if (!!ref) this.rowMap.set(item, ref);
  };

  _onRightPress = (item: T) => {
    const clickRow = this.rowMap.get(item);
    if (!!this.props.onHiddenAreaPress)
      this.props.onHiddenAreaPress(item, clickRow);
    else if (clickRow) clickRow.closeRow();
  };

  _onTouchStart = (item: T) => {
    const touchedRow = this.rowMap.get(item);
    if (!this.currentRow) this.currentRow = touchedRow;
    //当其他行接管触摸事件,关闭已打开的行
    else if (this.currentRow !== touchedRow) {
      this.currentRow.closeRow();
      this.currentRow = touchedRow;
    }
  };

  _renderItem = (items: { item: T; index: number }) => {
    const _rightContent = this.props.renderHiddenItem(items);
    const _renderItem = this.props.renderItem(items);
    const _key = 'r' + this.props.keyExtrator(items.item, items.index);
    const onGetRef = (ref: SwipeRow) => this.onGetRowRef(items.item, ref);
    const onRightPress = () => this._onRightPress(items.item);
    const onTouchStart = () => this._onTouchStart(items.item);

    return (
      <SwipeRow
        rightContent={_rightContent}
        rightContentWidth={this.props.hiddenItemWidth}
        key={_key}
        ref={onGetRef}
        onRightPress={onRightPress}
        lineHeight={this.props.lineHeight}
        onTouchStart={onTouchStart}>
        {_renderItem}
      </SwipeRow>
    );
  };

  onScroll = () => {
    this.currentRow && this.currentRow.closeRow();
  };

  render() {
    return (
      <View style={{ width: WINDOW_WIDTH }}>
        <FlatList
          data={this.props.data}
          renderItem={this._renderItem}
          keyExtractor={this.props.keyExtrator}
          ListHeaderComponent={this.props.ListHeaderComponent || null}
          ListFooterComponent={this.props.ListFooterComponent || null}
          ListEmptyComponent={this.props.ListEmptyComponent || null}
          onLayout={this.props.onLayout}
          alwaysBounceHorizontal={false}
          alwaysBounceVertical={false}
          onScroll={this.onScroll}
          getItemLayout={this.props.getItemLayout}
          directionalLockEnabled={true}
        />
      </View>
    );
  }
}

示例

  • 示范SwipeFlatList的使用方法
import React, { Component } from 'react';
import { View, Text, TouchableOpacity, Image, StyleSheet } from 'react-native';
import { WINDOW_WIDTH } from '../util';
import { SwipeFlatList } from './SwipeFlatList';
import { SwipeRow } from './SwipeRow';

type Item = {
  id: number
  icon: string;
  text: string;
};

type State = {
  list: Item[];
};

export default class Example extends Component<{}, State> {
  //模拟列表数据
  constructor(props: {}) {
    super(props);
    const mockList = []
    for (let i = 0; i < 30; i++) {
      mockList.push(
        {
          id: i,
          icon: 'https://s1.hdslb.com/bfs/static/jinkela/popular/assets/icon_rank.png',
          text: `我是第${i}`,
        }
      )
    }
    this.state = {
      list: mockList
    };
  }

  //删除列表数据
  deleteDataFromList = (rowId: number) => {
    this.setState({
      list: this.state.list.filter(item => item.id !== rowId)
    })
  }

  onRowPress = () => {
    console.log('you pressed row');
  };

  onHiddenAreaPressed = (item: Item, clickRow: SwipeRow | undefined) => {
    console.log('you click hidden area of item:', item.id)
    clickRow?.deleteRow(() => this.deleteDataFromList(item.id)) //在删除动画回调中再删除数据,防止动画未结束就rerender
  }

  //滑动行可见部分
  _renderItem = (items: { item: Item; index: number }) => {
    const { item } = items;

    return (
      <TouchableOpacity
        style={{ width: WINDOW_WIDTH, height: 64, alignItems: 'center', flexDirection: 'row', paddingHorizontal: 16, backgroundColor: 'white' }}
        onPress={this.onRowPress}
        activeOpacity={1}>
        <Image source={{ uri: item.icon }} style={{ width: 40, height: 40, marginRight: 8 }} />
        <Text style={{ lineHeight: 19, fontSize: 16, textAlign: 'center' }}>{item.text}</Text>
      </TouchableOpacity>
    );
  };

  //右侧隐藏部分
  _renderHiddenItem = () => {
    return (
      <View style={{ width: 72, height: 64, alignItems: 'center', justifyContent: 'center', backgroundColor: 'red' }}>
        <Text style={{ color: "white", fontSize: 16, lineHeight: 19 }}>删除</Text>
      </View>
    );
  };

  keyExtractor = (item: Item, index: number) => {
    return item.text + index;
  };

  //不需要可以去掉
  _ListEmptyComponent = () => (
    <View style={{ flex: 1 }}>
      <Text>Empty List</Text>
    </View>
  );

  //不需要可以去掉
  _ListHeaderComponent = () => (
    <View style={{ width: WINDOW_WIDTH, justifyContent: "center", alignItems: 'center', backgroundColor: 'white', height: 40 }}>
      <Text style={{ color: 'orange', fontSize: 16, lineHeight: 19, fontWeight: "600" }}>Swipe FlatList</Text>
    </View>
  );

  //不需要可以去掉
  _ListFooterComponent = () => (
    <View style={{ width: WINDOW_WIDTH, justifyContent: 'center', alignItems: 'center', height: 32 }}>
      <Text>no more data</Text>
    </View>
  );

  _getItemLayout = (item: Item[] | null | undefined, index: number) => {
    return {
      length: 64,
      offset: 64 * index + 40, // ListHeaderComponent.height
      index,
    };
  };

  render() {
    return (
      <View style={{ flex: 1, backgroundColor: 'gray' }}>
        <SwipeFlatList
          data={this.state.list}
          renderItem={this._renderItem}
          renderHiddenItem={this._renderHiddenItem}
          hiddenItemWidth={72}
          lineHeight={64}
          ListHeaderComponent={this._ListHeaderComponent()}
          ListFooterComponent={this._ListFooterComponent()}
          keyExtrator={this.keyExtractor}
          onHiddenAreaPress={this.onHiddenAreaPressed}
          onLayout={() => console.log("on layout")}
          getItemLayout={this._getItemLayout}
        />
      </View>
    );
  }
}

const styles = StyleSheet.create({});

后记

因为本人学习RN只有几个月,代码可能还有很多不完善的地方,仅供大家学习参考、拓展思路,希望对你有所帮助

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值