RN 纯js实现ios&Android地区选择控件

动画效果预览

思路

全世界国家地区和区号数据 => RN列表渲染 => 右侧首字母navigator与列表的联动 => 顶部输入搜索框与列表的联动 => 选中当前国家的数据回调

源码

import React from 'react';
import {View, Text, StyleSheet, TextInput, SectionList, ListView, TouchableHighlight, TouchableWithoutFeedback, Modal} from "react-native";
import PropTypes from 'prop-types';

const countryCodeSession = require('./lib/countryCode.json');

const styles = StyleSheet.create({
    container: {
        flex: 1,
        // flexDirection: 'column',
        justifyContent: "center",
        alignItems: "center",
        backgroundColor: "#F5FCFF",
    },
    container1: {
        flex: 1,
        backgroundColor: '#aaa',
        flexDirection: 'row',
        padding: 8,
    },
    container2: {
        flex: 11,
        flexDirection: 'row',
        paddingRight: 15,
        // backgroundColor: '#000'
    },
    searchInput: {
        flex: 1,
        backgroundColor: '#fff',
        borderRadius: 8,
        height: 40,
        paddingLeft: 10,
        marginTop: 3,
    },
    sessionList: {
        flex: 1
    },
    rightBar: {
        position: 'absolute',
        width: 15,
        right: 0,
        top: 70,
    },
    rightBarText: {
        color: 'blue',
        textAlign: 'center',
        lineHeight: 20
    },
    sessionListItemContainer: {
        flex: 1,
        flexDirection: 'row',
        padding: 8,
        paddingLeft: 0,
        // borderBottomWidth: 0.6,
        // borderBottomColor: '#eee'
    },
    sessionListItem1: {
        flex: 1
    },
    sessionListItem2: {
        flex: 1,
        textAlign: 'right',
        color: '#999'
    },
    sessionHeader: {
        backgroundColor: '#eee'
    },
    itemSeparator: {
        flex: 1,
        height: 1,
        backgroundColor: '#eee'
    },
    cancelBtn: {
        height: 40,
        lineHeight: 40,
        paddingLeft: 5,
    },
});

export default class extends React.Component {
    static propTypes= {
        isShow: PropTypes.bool,
        onPick: PropTypes.func,
        animationType: PropTypes.string,
        // onCancel: PropTypes.func
    };
    sectionlist: SectionList;
    constructor(props) {
        super(props);
        this.state = {
            fullList: true,
            matchItem: new Set(),
            matchSection: new Set(),
            hideRightBar: false,
            isShow: this.props.isShow
        };
        this.handleRightBarPress = this.handleRightBarPress.bind(this);
        this.searchList = this.searchList.bind(this);
    };
    handleRightBarPress (itemIndex) {
        this.sectionlist.scrollToLocation({itemIndex: itemIndex})
    };
    searchList (text) {
        this.setState({fullList: false});
        if (!text) {
            this.setState({fullList: true});
            return
        }
        if (~text.indexOf(' ')) {
            this.setState({fullList: false});
            return
        }
        let matchItem = new Set();
        let matchSection = new Set();
        for (let i = 0; i < countryCodeSession.length; i++) {
            for (let j = 0; j < countryCodeSession[i].data.length; j++) {
                if (countryCodeSession[i].data[j].phoneCode.toString().match(text) || countryCodeSession[i].data[j].countryName.match(text)) {
                    matchItem.add(countryCodeSession[i].data[j].countryCode);
                    !matchSection.has(countryCodeSession[i].key) && matchSection.add(countryCodeSession[i].key);
                }
            }
        }
        if (matchItem.size) {
            this.setState({matchItem, matchSection})
        } else {
            this.setState({matchItem, matchSection}, () => {
                this.setState({fullList: false})
            })
        }
    };
    phoneCodeSelected (item) {
        this.props.onPick(item)
        this.setState({isShow: false})
    };
    render(){
        const title = this.props.title || 'No Title';
        const data = this.props.data || 'No Data';
        const sectionMapArr = [
            ['A', -1],
            ['B', 20],
            ['C', 47],
            ['D', 51],
            ['E', 59],
            ['F', 64],
            ['G', 78],
            ['H', 93],
            ['I', 104],
            ['J', 106],
            ['K', 119],
            ['L', 132],
            ['M', 146],
            ['N', 176],
            ['O', 191],
            ['P', 193],
            ['Q', 198],
            ['R', 200],
            ['S', 205],
            ['T', 233],
            ['U', 247],
            ['V', 249],
            ['W', 251],
            ['X', 262],
            ['Y', 272],
            ['Z', 288]
        ];
        let ds = new ListView.DataSource({rowHasChanged: (r1, r2) => r1 !== r2});
        return (
            <Modal visible={this.state.isShow} animationType={this.props.animationType || 'slide'} transparent={false}>
                <View style={styles.container}>
                    <View style={[styles.container1]}>
                        <TextInput
                            style={[styles.searchInput]}
                            placeholder='请输入国家或地区'
                            onChangeText={(text) => this.searchList(text)}
                            onFocus={() => this.setState({hideRightBar: true})}
                        />
                        <TouchableWithoutFeedback onPress={() => this.setState({isShow: false})}>
                            <Text style={[styles.cancelBtn]}>X</Text>
                        </TouchableWithoutFeedback>
                    </View>
                    <View style={[styles.container2]}>
                        <SectionList
                            ref={w => this.sectionlist = w}
                            initialNumToRender={300}
                            style={[styles.sessionList]}
                            renderItem={({item}) => (this.state.matchItem.has(item.countryCode) || this.state.fullList) ? <TouchableHighlight onPress={() => this.phoneCodeSelected(item)}><View style={[styles.sessionListItemContainer]} ><Text style={[styles.sessionListItem1]}>{item.countryName}</Text><Text style={[styles.sessionListItem2]}>+{item.phoneCode}</Text></View></TouchableHighlight>: <View></View>}
                            renderSectionHeader={({section, index}) => (this.state.matchSection.has(section.key) || this.state.fullList) ? <View><Text style={[styles.sessionHeader]}>{section.key}</Text></View> : <View></View>}
                            sections={countryCodeSession}
                            ItemSeparatorComponent={() => this.state.fullList ? <View style={[styles.itemSeparator]}></View> : <View></View>}
                        />
                    </View>
                    <View style={[styles.rightBar]}>
                        {this.state.hideRightBar ? <View></View> : <ListView
                            dataSource={ds.cloneWithRows(sectionMapArr)}
                            renderRow={(rowData) => <Text style={[styles.rightBarText]} onPress={() => this.handleRightBarPress(rowData[1])}>{rowData[0]}</Text>}
                        />}
                    </View>
                </View>
            </Modal>
        );
    }
}

1.全世界国家地区和区号数据

数据来源基于 https://github.com/mohuilin/CountryCode,在此基础上重新改造了数据以适应RN 列表渲染,改造后的数据 https://github.com/StephenKe/react-native-country-code-picker/tree/master/lib ,就是源码中的json数据

const countryCodeSession = require('./lib/countryCode.json');

2.RN列表渲染

<Modal visible={this.state.isShow} animationType={this.props.animationType || 'slide'} transparent={false}>
                <View style={styles.container}>
                    <View style={[styles.container1]}>
                        <TextInput
                            style={[styles.searchInput]}
                            placeholder='请输入国家或地区'
                            onChangeText={(text) => this.searchList(text)}
                            onFocus={() => this.setState({hideRightBar: true})}
                        />
                        <TouchableWithoutFeedback onPress={() => this.setState({isShow: false})}>
                            <Text style={[styles.cancelBtn]}>X</Text>
                        </TouchableWithoutFeedback>
                    </View>
                    <View style={[styles.container2]}>
                        <SectionList
                            ref={w => this.sectionlist = w}
                            initialNumToRender={300}
                            style={[styles.sessionList]}
                            renderItem={({item}) => (this.state.matchItem.has(item.countryCode) || this.state.fullList) ? <TouchableHighlight onPress={() => this.phoneCodeSelected(item)}><View style={[styles.sessionListItemContainer]} ><Text style={[styles.sessionListItem1]}>{item.countryName}</Text><Text style={[styles.sessionListItem2]}>+{item.phoneCode}</Text></View></TouchableHighlight>: <View></View>}
                            renderSectionHeader={({section, index}) => (this.state.matchSection.has(section.key) || this.state.fullList) ? <View><Text style={[styles.sessionHeader]}>{section.key}</Text></View> : <View></View>}
                            sections={countryCodeSession}
                            ItemSeparatorComponent={() => this.state.fullList ? <View style={[styles.itemSeparator]}></View> : <View></View>}
                        />
                    </View>
                    <View style={[styles.rightBar]}>
                        {this.state.hideRightBar ? <View></View> : <ListView
                            dataSource={ds.cloneWithRows(sectionMapArr)}
                            renderRow={(rowData) => <Text style={[styles.rightBarText]} onPress={() => this.handleRightBarPress(rowData[1])}>{rowData[0]}</Text>}
                        />}
                    </View>
                </View>
            </Modal>

DOM结构从外向内看:

Modal

触发控件相当于在当前页面覆盖一个Modal,Modal的visible和animationType属性接收外部传入参数,不传默认是visible: false,animationType: 'slide'

SectionList

SectionList的属性能极好的实现该控件,具体有关它的介绍请移步RN官网

  • ref: 将SectionList实例赋给this.sectionlist,后面可以直接使用this.sectionlist...调起所有SectionList实例方法
  • initialNumToRender: 初始化列表时加载几列,这里我写死的300,因为整个渲染数据不过200多条,是让它一次性全部渲染出来,后续优化可以按需加载,SectionList有提供相关方法
  • sections: 列表加载的源数据
  • ItemSeparatorComponent: 列表行间分割组件。这里是一条线,由this.state.fullList控制是否渲染
  • renderSectionHeader: 分组数据。源数据中根据地区首字母分组,对应key字段
  • renderItem: 渲染行。这里显示源数据的countryName和phoneCode字段,这里当输入框检索进行过滤操作的时候只需要显示符合条件的行,就是this.state.matchItem.has(item.countryCode) || this.state.fullList 的含义,具体逻辑控制已注释:
searchList (text) {
        // 重置列表初始状态
        this.setState({fullList: false}); 
        // 输入为空时重置列表初始状态
        if (!text) {
            this.setState({fullList: true});
            return
        }
        // 输入不为空时不渲染行间分割线
        if (~text.indexOf(' ')) {
            this.setState({fullList: false});
            return
        }
        let matchItem = new Set();
        let matchSection = new Set();
        for (let i = 0; i < countryCodeSession.length; i++) {
            for (let j = 0; j < countryCodeSession[i].data.length; j++) {
                // 匹配当前检索到的数据
                // 检索到的所有行的countryCode存入matchItem
                // 检索到的所有行对应的分组key存入matchSection
                if (countryCodeSession[i].data[j].phoneCode.toString().match(text) || countryCodeSession[i].data[j].countryName.match(text)) {
                    matchItem.add(countryCodeSession[i].data[j].countryCode);
                    !matchSection.has(countryCodeSession[i].key) && matchSection.add(countryCodeSession[i].key);
                }
            }
        }
        if (matchItem.size) {
            // 当前有检索到数据则重新渲染
            this.setState({matchItem, matchSection})
        } else {
            // 当前没有检索到数据则重置列表状态
            this.setState({matchItem, matchSection}, () => {
                this.setState({fullList: false})
            })
        }
    };

当前行被选中触发phoneCodeSelected函数,将当前选中的行数据回调:

phoneCodeSelected (item) {
        this.props.onPick(item)
        this.setState({isShow: false})
    };
TextInput
  • onChangeText: 触发searchList函数,以上已解释
  • onFocus: 当输入框被选中时不渲染右侧首字母navigator
TouchableWithoutFeedback
  • onPress: 隐藏控件
ListView
  • dataSource: 源数据sectionMapArr,每行渲染首字母并且没个首字母关联了sectionlist对应的itemIndex (根据不同数据源itemIndex对应具体数值也不同)
const sectionMapArr = [
            ['A', -1],
            ['B', 20],
            ['C', 47],
            ['D', 51],
            ['E', 59],
            ['F', 64],
            ['G', 78],
            ['H', 93],
            ['I', 104],
            ['J', 106],
            ['K', 119],
            ['L', 132],
            ['M', 146],
            ['N', 176],
            ['O', 191],
            ['P', 193],
            ['Q', 198],
            ['R', 200],
            ['S', 205],
            ['T', 233],
            ['U', 247],
            ['V', 249],
            ['W', 251],
            ['X', 262],
            ['Y', 272],
            ['Z', 288]
        ];
  • onPress: 触发handleRightBarPress函数,sectionlist跳转到对应的首字母分组
handleRightBarPress (itemIndex) {
        this.sectionlist.scrollToLocation({itemIndex: itemIndex})
    };

总结

该控件已开源 https://github.com/StephenKe/react-native-country-code-picker ,并且已在npm发布,可以在项目中使用:

npm install react-native-country-code-picker --save

or

yarn add react-native-country-code-picker --save

使用很简单,具体可查看README
欢迎star、fork、issue、pr
- 0-

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 214,504评论 6 496
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 91,434评论 3 389
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 160,089评论 0 349
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 57,378评论 1 288
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 66,472评论 6 386
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 50,506评论 1 292
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,519评论 3 413
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 38,292评论 0 270
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,738评论 1 307
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 37,022评论 2 329
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 39,194评论 1 342
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,873评论 5 338
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 40,536评论 3 322
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 31,162评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,413评论 1 268
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 47,075评论 2 365
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 44,080评论 2 352

推荐阅读更多精彩内容