天天看點

React-Native ListView拖拽交換Item

在高仿“掘金”用戶端的那個項目中,你會發現在打開和關閉“首頁展示标簽”中,我并沒有實作可拖拽換位item的效果。不過在自己新寫的Gank.io項目中,将這一功能實作了一把,在此記錄一下。先上效果圖

React-Native ListView拖拽交換Item

對,就是這樣~

在實作這個效果前,我的思路是這樣的,布局->item可點選突出顯示->可移動item->可交換item->擡起手指恢複正确的位置。下面一一解釋。

布局

忘了說了,由于這個界面的item的元素較少,并且為了友善起見,我并沒有采用ListView控件去實作這個list,而是使用數組map傳回一個個itemView。

render(){
        return(
            <View style={styles.container}>
                <NavigationBar
                    title="首頁内容展示順序"
                    isBackBtnOnLeft={true}
                    leftBtnIcon="arrow-back"
                    leftBtnPress={this._handleBack.bind(this)}
                />
                {this.names.map((item, i)=>{
                    return (
                        <View
                            {...this._panResponder.panHandlers}
                            ref={(ref) => this.items[i] = ref}
                            key={i}
                            style={[styles.item, {top: (i+1)*49}]}>
                            <Icon name="ios-menu" size={px2dp(25)} color="#ccc"/>
                            <Text style={styles.itemTitle}>{item}</Text>
                        </View>
                    );
                })}
            </View>
        );
    }
           

前面NavigationBar部分不用看,自己封裝的元件,通過map函數,可以依次周遊每個數組元素(this.names = ['Android','iOS','前端','拓展資源','休息視訊'];)。因為我們需要後面能直接控制每個DOM(後面會直接操控它的樣式),是以需要添加ref屬性,不熟悉或者不明白ref這個prop的,可以參考 這裡。還需要注意的地方是,因為我們的item是可以拖拽移動的,能直接操控它們位置屬性的就是 絕對和 相對布局,提供了top,left,right,bottom這些個props。貼一下item的stylesheet。

item: {
        flexDirection: 'row',
        height: px2dp(49),
        width: theme.screenWidth,
        alignItems: 'center',
        backgroundColor: '#fff',
        paddingLeft: px2dp(20),
        borderBottomColor: theme.segment.color,
        borderBottomWidth: theme.segment.width,
        position: 'absolute',
    },
           

不用在意其他的props,最關鍵的最起作用的就是position屬性,一旦設定,該View的位置就不會受控于flexbox的布局了,直接浮動受控于top,left這幾個參數。對于{...this._panResponder.panHandlers} 這個屬性,就會談到react-native中的手勢,也就是我們下一個内容。

item可點選突出顯示

如果不了解react-native中的手勢,建議簡單去了解下, 直通車在這裡還有 這個。一旦需要自己實作手勢,我們需要實作這幾個方法。

onStartShouldSetPanResponder: (evt, gestureState) => true, //開啟手勢響應
      onMoveShouldSetPanResponder: (evt, gestureState) => true,  //開啟移動手勢響應

      onPanResponderGrant: (evt, gestureState) => {              //手指觸碰螢幕那一刻觸發
        
      },
      onPanResponderMove: (evt, gestureState) => {               //手指在螢幕上移動觸發
        
      },
      onPanResponderTerminationRequest: (evt, gestureState) => true,   //當有其他不同手勢出現,響應是否中止目前的手勢
      onPanResponderRelease: (evt, gestureState) => {           //手指離開螢幕觸發
        
      },
      onPanResponderTerminate: (evt, gestureState) => {         //目前手勢中止觸發
        
      },
           

簡單介紹了下幾個函數的意義,是以很明顯,要實作item點選突出顯示,我們需要在onPanRespondedGrant這裡做事情。貼代碼來解釋,

onPanResponderGrant: (evt, gestureState) => {
                const {pageY, locationY} = evt.nativeEvent;   //1
                this.index = this._getIdByPosition(pageY);    //2
                this.preY = pageY - locationY;                //3
                //get the taped item and highlight it
                let item = this.items[this.index];            //4
                item.setNativeProps({                         //5
                    style: {
                        shadowColor: "#000",                  //6
                        shadowOpacity: 0.3,                   //6
                        shadowRadius: 5,                      //6
                        shadowOffset: {height: 0, width: 2},  //6
                        elevation: 5                          //7
                    }
                });
            },
           

1. evt參數有個nativeEvent對象,其中包含了一系列的參數,包括點選的位置,有幾個手指點選螢幕等等。pageY是相對于根節點的位置,locationY是相對于元素自己。 2. 通過這個pageY我們需要計算出這個點上是對應的哪一個item,由于我的布局簡單,寫個函數來計算了下,

_getIdByPosition(pageY){
        var id = -1;
        const height = px2dp(49);

        if(pageY >= height && pageY < height*2)
            id = 0;
        else if(pageY >= height*2 && pageY < height*3)
            id = 1;
        else if(pageY >= height*3 && pageY < height*4)
            id = 2;
        else if(pageY >= height*4 && pageY < height*5)
            id = 3;
        else if(pageY >= height*5 && pageY < height*6)
            id = 4;

        return id;
    }
           

3. this.preY儲存目前正确點選item的位置,為了後面移動item。 4. 有了this.index,我們就可以擷取到點選的是哪一個DOM了。 5. 是以這一步就是直接修改DOM的屬性,将其突出顯示 6. iOS中陰影屬性 7. Android中陰影設定

可移動item

這一步應該也可以想到我們需要在onPanResponderMove裡操作。讓其移動就是不斷的将evt.nativeEvent中位置資訊去指派給item的top屬性,這個比較簡單,

onPanResponderMove: (evt, gestureState) => {
                let top = this.preY + gestureState.dy;
                let item = this.items[this.index];
                item.setNativeProps({
                    style: {top: top}
                });
            },
           

可交換item

這個是最核心的部分了,思路是這樣的,當我們點選某個item并且開始移動它的時候,我們還需要計算下,目前這個手指移動到的位置有沒有進入别的Item範圍,如果有,OK,我們将進入到的那個item位置放到我們手上拿着的這個item的位置。因為有了之前的函數——通過位置計算id,我們可以很快的求出是否這個位置傳回的id和我們手上這個item的id一樣。

onPanResponderMove: (evt, gestureState) => {
                let top = this.preY + gestureState.dy;
                let item = this.items[this.index];
                item.setNativeProps({
                    style: {top: top}
                });

                let collideIndex = this._getIdByPosition(evt.nativeEvent.pageY);  //擷取目前的位置上item的id
                if(collideIndex !== this.index && collideIndex !== -1) {          //判斷是否和手上的item的id一樣
                    let collideItem = this.items[collideIndex];
                    collideItem.setNativeProps({
                        style: {top: this._getTopValueYById(this.index)}         //将collideItem的位置移動到手上的item的位置
                    });
                    //swap two values
                    [this.items[this.index], this.items[collideIndex]] = [this.items[collideIndex], this.items[this.index]];
                    this.index = collideIndex;
                }
            },
           

在swap two value這裡,我們還需要做一件很重要的事,當位置此時發生交換時,對應的item的id值我們需要進行一下交換,不然下一次再碰撞檢測時,collideItem移動到的位置始終都是我們手上拿的item的初始位置。PS:這裡我用的ES6的文法交換兩個數的數值。

擡起手指恢複正确的位置

擡起手指時,我們需要做兩件事: 1.将手上拿起的item的屬性恢複原樣,2. 将其擺到正确的位置上。 第一個設定屬性很簡單,當初怎麼改的,就怎麼改回去,用setNativeProps。第二個也簡單,因為我們在移動和交換過程中,始終保持id對應正确的item,是以我們隻要有了id就可以計算出正确的位置。

onPanResponderRelease: (evt, gestureState) => {
                const shadowStyle = {
                    shadowColor: "#000",
                    shadowOpacity: 0,
                    shadowRadius: 0,
                    shadowOffset: {height: 0, width: 0,},
                    elevation: 0
                };
                let item = this.items[this.index];
                //go back the correct position
                item.setNativeProps({
                    style: {...shadowStyle, top: this._getTopValueYById(this.index)}
                });
            },
           

忘了在之前貼一下根據id計算位置的函數了,

_getTopValueYById(id){
        const height = px2dp(49);
        return (id + 1) * height;
    }
           

因為我的NavigationBar也是行高49,是以id為0的第一item位置就應該1*49。這樣就容易了解這個代碼了吧。

Anything Else?Finish it?

咱們的資料結構呢?這個隻是界面作出了改動了,我們的資料還需要做出相應的變化,這裡簡單起見,我在構造函數中,添加了this.order=[ ],當開始map時,我們就将各個item的名字push進去,是以這個數組的順序就代表着這個list的順序。

{this.names.map((item, i)=>{
                    this.order.push(item);  //add code at here
                    return (
                        <View
                            {...this._panResponder.panHandlers}
                            ref={(ref) => this.items[i] = ref}
                            key={i}
                            style={[styles.item, {top: (i+1)*49}]}>
                            <Icon name="ios-menu" size={px2dp(25)} color="#ccc"/>
                            <Text style={styles.itemTitle}>{item}</Text>
                        </View>
                    );
                })}
           

當開始交換位置時,這個order也需要交換。

//swap two values
[this.items[this.index], this.items[collideIndex]] = [this.items[collideIndex], this.items[this.index]];
[this.order[this.index], this.order[collideIndex]] = [this.order[collideIndex], this.order[this.index]];  //add code at here
this.index = collideIndex;
           

OK,至此,大功告成,完成。完整代碼最後貼出來。

關于新項目

目前正在做這個新項目,因為上一個“掘金”項目,畢竟api不公開,偷偷擷取資料流别人不怪罪已經很感謝了,而且有的資料擷取不到,是以做不了一個完整的react-native項目,最近在用gank.io的公開api在做一個全新的項目,從界面設計到代碼架構(Redux架構)都是一次全新的體驗,畢竟上一個項目是第一個,還是摸索,這一次将會更加熟練,會重新規範代碼結構和命名。

是以歡迎大家可以關注我的 新項目,PS:這個項目仍然處在開發階段,當完成時,會再一次部落格記錄這次開發旅程~

完整代碼

有些代碼是自己封裝的,不用理會

/**
 * Created by wangdi on 27/11/16.
 */
'use strict';

import React, {Component, PropTypes} from 'react';
import {StyleSheet, View, Text, PanResponder} from 'react-native';
import Icon from 'react-native-vector-icons/Ionicons';
import BackPageComponent from '../BackPageComponent';
import NavigationBar from '../../components/NavigationBar';
import px2dp from '../../utils/px2dp';
import theme from '../../constants/theme';

export default class OrderContentPage extends BackPageComponent{
    constructor(props){
        super(props);
        this.names = ['Android','iOS','前端','拓展資源','休息視訊'];
        this.items = [];
        this.order = [];
    }

    render(){
        return(
            <View style={styles.container}>
                <NavigationBar
                    title="首頁内容展示順序"
                    isBackBtnOnLeft={true}
                    leftBtnIcon="arrow-back"
                    leftBtnPress={this._handleBack.bind(this)}
                />
                {this.names.map((item, i)=>{
                    this.order.push(item);
                    return (
                        <View
                            {...this._panResponder.panHandlers}
                            ref={(ref) => this.items[i] = ref}
                            key={i}
                            style={[styles.item, {top: (i+1)*49}]}>
                            <Icon name="ios-menu" size={px2dp(25)} color="#ccc"/>
                            <Text style={styles.itemTitle}>{item}</Text>
                        </View>
                    );
                })}
            </View>
        );
    }

    componentWillMount(){
        this._panResponder = PanResponder.create({
            onStartShouldSetPanResponder: (evt, gestureState) => true,
            onMoveShouldSetPanResponder: (evt, gestureState) => true,
            onPanResponderGrant: (evt, gestureState) => {
                const {pageY, locationY} = evt.nativeEvent;
                this.index = this._getIdByPosition(pageY);
                this.preY = pageY - locationY;
                //get the taped item and highlight it
                let item = this.items[this.index];
                item.setNativeProps({
                    style: {
                        shadowColor: "#000",
                        shadowOpacity: 0.3,
                        shadowRadius: 5,
                        shadowOffset: {height: 0, width: 2},
                        elevation: 5
                    }
                });
            },
            onPanResponderMove: (evt, gestureState) => {
                let top = this.preY + gestureState.dy;
                let item = this.items[this.index];
                item.setNativeProps({
                    style: {top: top}
                });

                let collideIndex = this._getIdByPosition(evt.nativeEvent.pageY);
                if(collideIndex !== this.index && collideIndex !== -1) {
                    let collideItem = this.items[collideIndex];
                    collideItem.setNativeProps({
                        style: {top: this._getTopValueYById(this.index)}
                    });
                    //swap two values
                    [this.items[this.index], this.items[collideIndex]] = [this.items[collideIndex], this.items[this.index]];
                    [this.order[this.index], this.order[collideIndex]] = [this.order[collideIndex], this.order[this.index]];
                    this.index = collideIndex;
                }
            },
            onPanResponderTerminationRequest: (evt, gestureState) => true,
            onPanResponderRelease: (evt, gestureState) => {
                const shadowStyle = {
                    shadowColor: "#000",
                    shadowOpacity: 0,
                    shadowRadius: 0,
                    shadowOffset: {height: 0, width: 0,},
                    elevation: 0
                };
                let item = this.items[this.index];
                //go back the correct position
                item.setNativeProps({
                    style: {...shadowStyle, top: this._getTopValueYById(this.index)}
                });
                console.log(this.order);
            },
            onPanResponderTerminate: (evt, gestureState) => {
                // Another component has become the responder, so this gesture
                // should be cancelled
            }
        });
    }

    _getIdByPosition(pageY){
        var id = -1;
        const height = px2dp(49);

        if(pageY >= height && pageY < height*2)
            id = 0;
        else if(pageY >= height*2 && pageY < height*3)
            id = 1;
        else if(pageY >= height*3 && pageY < height*4)
            id = 2;
        else if(pageY >= height*4 && pageY < height*5)
            id = 3;
        else if(pageY >= height*5 && pageY < height*6)
            id = 4;

        return id;
    }

    _getTopValueYById(id){
        const height = px2dp(49);
        return (id + 1) * height;
    }
}

const styles = StyleSheet.create({
    container: {
        flex: 1,
        backgroundColor: theme.pageBackgroundColor
    },
    item: {
        flexDirection: 'row',
        height: px2dp(49),
        width: theme.screenWidth,
        alignItems: 'center',
        backgroundColor: '#fff',
        paddingLeft: px2dp(20),
        borderBottomColor: theme.segment.color,
        borderBottomWidth: theme.segment.width,
        position: 'absolute',
    },
    itemTitle: {
        fontSize: px2dp(15),
        color: '#000',
        marginLeft: px2dp(20)
    }
});      

繼續閱讀