在高仿“掘金”客户端的那个项目中,你会发现在打开和关闭“首页展示标签”中,我并没有实现可拖拽换位item的效果。不过在自己新写的Gank.io项目中,将这一功能实现了一把,在此记录一下。先上效果图
对,就是这样~
在实现这个效果前,我的思路是这样的,布局->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可点击突出显示
onStartShouldSetPanResponder: (evt,gestureState) => true,//开启手势响应 onMoveShouldSetPanResponder: (evt,//开启移动手势响应 onPanResponderGrant: (evt,gestureState) => { //手指触碰屏幕那一刻触发 },onPanResponderMove: (evt,gestureState) => { //手指在屏幕上移动触发 },onPanResponderTerminationRequest: (evt,//当有其他不同手势出现,响应是否中止当前的手势 onPanResponderRelease: (evt,gestureState) => { //手指离开屏幕触发 },onPanResponderTerminate: (evt,gestureState) => { //当前手势中止触发 },
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 } }); },
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了。
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; }
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[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架构)都是一次全新的体验,毕竟上一个项目是第一个,还是摸索,这一次将会更加熟练,会重新规范代码结构和命名。
完整代码
有些代码是自己封装的,不用理会
/** * Created by wangdi on 27/11/16. */ 'use strict'; @H_502_169@import React,{Component,PropTypes} @H_502_169@from 'react'; @H_502_169@import {StyleSheet,View,Text,PanResponder} @H_502_169@from 'react-native'; @H_502_169@import Icon @H_502_169@from 'react-native-vector-icons/Ionicons'; @H_502_169@import BackPageComponent @H_502_169@from '../BackPageComponent'; @H_502_169@import NavigationBar @H_502_169@from '../../components/NavigationBar'; @H_502_169@import px2dp @H_502_169@from '../../utils/px2dp'; @H_502_169@import theme @H_502_169@from '../../constants/theme'; @H_502_169@export default class OrderContentPage @H_502_169@extends BackPageComponent{ constructor(props){ @H_502_169@super(props); @H_502_169@this.names = ['Android','iOS','前端','拓展资源','休息视频']; @H_502_169@this.items = []; @H_502_169@this.order = []; } render(){ @H_502_169@return( <View style={styles.container}> <NavigationBar title="首页内容展示顺序" isBackBtnOnLeft={@H_502_169@true} leftBtnIcon="arrow-back" leftBtnPress={@H_502_169@this._handleBack.bind(@H_502_169@this)} /> {@H_502_169@this.names.map((item,i)=>{ @H_502_169@this.order.push(item); @H_502_169@return ( <View {...@H_502_169@this._panResponder.panHandlers} ref={(ref) => @H_502_169@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(){ @H_502_169@this._panResponder = PanResponder.create({ onStartShouldSetPanResponder: (evt,gestureState) => @H_502_169@true, onMoveShouldSetPanResponder: (evt, onPanResponderGrant: (evt,gestureState) => { @H_502_169@const {pageY,locationY} = evt.nativeEvent; @H_502_169@this.index = @H_502_169@this._getIdByPosition(pageY); @H_502_169@this.preY = pageY - locationY; //get the taped item and highlight it @H_502_169@let item = @H_502_169@this.items[@H_502_169@this.index]; item.setNativeProps({ style: { shadowColor: "#000", shadowOpacity: 0.3, shadowRadius: 5, shadowOffset: {height: 0,width: 2}, elevation: 5 } }); }, onPanResponderMove: (evt,gestureState) => { @H_502_169@let top = @H_502_169@this.preY + gestureState.dy; @H_502_169@let item = @H_502_169@this.items[@H_502_169@this.index]; item.setNativeProps({ style: {top: top} }); @H_502_169@let collideIndex = @H_502_169@this._getIdByPosition(evt.nativeEvent.pageY); @H_502_169@if(collideIndex !== @H_502_169@this.index && collideIndex !== -1) { @H_502_169@let collideItem = @H_502_169@this.items[collideIndex]; collideItem.setNativeProps({ style: {top: @H_502_169@this._getTopValueYById(@H_502_169@this.index)} }); //swap two values [@H_502_169@this.items[@H_502_169@this.index],@H_502_169@this.items[collideIndex]] = [@H_502_169@this.items[collideIndex],@H_502_169@this.items[@H_502_169@this.index]]; [@H_502_169@this.order[@H_502_169@this.index],@H_502_169@this.order[collideIndex]] = [@H_502_169@this.order[collideIndex],@H_502_169@this.order[@H_502_169@this.index]]; @H_502_169@this.index = collideIndex; } }, onPanResponderTerminationRequest: (evt, onPanResponderRelease: (evt,gestureState) => { @H_502_169@const shadowStyle = { shadowColor: "#000", shadowOpacity: 0, shadowRadius: 0, shadowOffset: {height: 0,width: 0, elevation: 0 }; @H_502_169@let item = @H_502_169@this.items[@H_502_169@this.index]; //go back the correct position item.setNativeProps({ style: {...shadowStyle,top: @H_502_169@this._getTopValueYById(@H_502_169@this.index)} }); console.log(@H_502_169@this.order); }, onPanResponderTerminate: (evt,gestureState) => { // Another component has become the responder,so this gesture // should be cancelled } }); } _getIdByPosition(pageY){ @H_502_169@var id = -1; @H_502_169@const height = px2dp(49); @H_502_169@if(pageY >= height && pageY < height*2) id = 0; @H_502_169@else if(pageY >= height*2 && pageY < height*3) id = 1; @H_502_169@else if(pageY >= height*3 && pageY < height*4) id = 2; @H_502_169@else if(pageY >= height*4 && pageY < height*5) id = 3; @H_502_169@else if(pageY >= height*5 && pageY < height*6) id = 4; @H_502_169@return id; } _getTopValueYById(id){ @H_502_169@const height = px2dp(49); @H_502_169@return (id + 1) * height; } } @H_502_169@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) } });