记一个 'Image 图片浏览器' 开源组件的开发流程

在一次项目开发过程当中,发现本身着手的业务中有一个比较经常使用的功能模块。就如同‘微信朋友圈,微博和Twitter’等App比较经常使用的一个功能,图文消息中图片的排版和图片详情的浏览。由于在github和js.coach找一些公共的可直接用的开源组件不是能很好的对应本身的业务逻辑,因此打算根据本身的业务场景,开发一个符合本身要求的组件出来,而且把开发过程总结并记录下来。给本身以从新整理加深学习,给后来者以借鉴。javascript

  • 最终实现效果

  1. 图片排版:最多可显示的图片量为9,多余图片数量以数字显示出来。每一个行排版的时候,以一行最多3个,不够3个按照3等距去平分长度。
  2. 显示加载:加载网络图片的时候,没有加载出图片的时候显示loading,加载失败的时候显示失败图片。
  3. 查看详情:点击任意一张加载成功的图片时候,单独页面显示图片大图。
  4. 详情浏览:左右滑动时候显示相邻加载成功的图片,直到滑动到第一张图片或者最后一张图片。

  • github地址:react-native-images-browse

  • 拆分对应实现的功能块

    1. 图片的排版样式
    2. 加载时的样式和加载错误图片的替换
    3. 点击任意图片逻辑
    4. 点击后的大图查看排版和基本的过渡动画
    5. 查看详情时的排版方式和左右滑动逻辑
  • 功能模块的具体实现

    一 、图片的排版样式

    对于每行最多3个不够3个按照3等距平分,最终最多显示9个这样的排版方式。能够考虑flex配合flexDirection: "row"以及flexWrap: "wrap"的策略来作。html

    可是,这里有一个问题。那就是咱们须要作一个3行的图片排列方式,同时还要知足当前行不足3张的时候按照等距去平分长度。也就是是说,不论咱们最终显示多少图片,咱们的最终排版样式是都不会有缺口的,咱们都要把他填充完整。那么,若是咱们单纯的使用flex配合flexDirection: "row"以及flexWrap: "wrap"来作的话,咱们必然要先获取设备屏幕的宽。而后为每个分配等额的比例尺寸,而后在计算好每一个之间的边距等等。用一个完整的图片数据去遍历到这些已经准备好的容器中。前端

    那么,你就会发现,每个图片的大小都是固定的。没有作到咱们刚才想要的那个效果。java

    因此,咱们须要稍稍更换一个策略。react

    咱们依然要用flex配合flexDirection: "row"以及`flexWrap: "wrap"。如今咱们要把数据分红3组来作(若是你是打算作4组,那就分红4组)。咱们能够确立3组空的数组,而后根据传递来的数据,按照满3个存成一组,不满3个跟在后面的原则去切分这个数据。android

    而后分别去判断这3个组的内容是否存在,再判断组中的数据数量,按照比例分配不一样的尺寸到每一个图片的宽上(高是固定的)。组内数据越多(最多3个),所分配出来的宽比重就越小(最少1个)。反之就越大。git

    // 初始化数据
    constructor(props) {
            super(props);
            this.state = {
                imgLineA: [], // 第一行显示
                imgLineB: [], // 第二行显示
                imgLineC: []  // 第三行显示
            };
        }
    
    
    componentWillMount() {
            const { imgSource } = this.props; // image数据源
    
            if (!isEmpty(imgSource)) {
                const _imgSize = imgSource.length;
                const _partSize = Math.ceil(_imgSize / 3);
    
                let _partArray = [];
    
                for (let i = 0, j = 1; i < _partSize; i++, j++) {
                    // 以3个一组切分image数据源
                    _partArray = _partArray.concat(imgSource.slice(i * 3, j * 3 > imgSource.length ? imgSource.length : j * 3));
                
                    // 分别装在3个容器当中
                    if (i === 0) {
                        this.setState({
                            imgLineA: _partArray
                        });
                    } else if (i === 1) {
                        this.setState({
                            imgLineB: _partArray
                        });
                    } else if (i === 2) {
                        this.setState({
                            imgLineC: _partArray
                        });
                    }
                    // 将临时容器置空
                    _partArray = [];
                }
            }
        }
    
    render() {
        return(
            <View style={{flex:1}}> { isEmpty(this.state.imgLineA) ?null : <View style={styles.showImgView}> { imgSLineTop.map((imgData, key) => { return ( <TouchableOpacity key={key} style={{flex: 1}} activeOpacity={0.8} onPress={() => this.props.imgClick(key)}> <ImageChild loadImgUrl={imgData} imgNum={imgSLineTop}/> </TouchableOpacity> ); }) } </View> } ... { picNum >= 0 ? <View style={styles.visBaView}> <Text style={styles.visText}> {`+ ${picNum}`} </Text> </View> : null } </View> ) } 复制代码

    2、加载时的样式和加载错误图片的替换

    对于网络图片的加载,这个过程在js中必定是一个异步任务,属于耗时操做。因此,网络资源图片的获取速度跟其所在的网络位置,请求时限和当前的网络状态有关。为了更好的用户体验,咱们决定在开发的过程当中加入一种保护。github

    1. 当网络图片正在加载的时候,显示loading动画图。
    2. 当网络图片加载完成的时候,在容器位置添加显示的图片。
    3. 当网络图片加载错误的时候,显示一个本地的默认错误图片。

    那么,就是在咱们实现的时候能够以每一行为单位,去分别加载。当时在设计时单纯的考虑将每个图片循环加载到<Image />中,利用ImageonLoad方法将尚未加载成功的显示loading动画,利用ImageonError方法将加载失败的图片显示为默认代替图片。 可是,这个中存在一个问题。那就是onLoadonError方法都是异步的,在作加载资源判断的时候,每每地址错误的图片会请求更长的时间。所以,同时存在的这俩个方法不能按照正确的加载正确或者错误的顺序返回,这就存在一个问题。那就是全部资源地址正确的图片会被先加载完成,全部的资源地址错误的图片最后加载。这就致使了显示的位置错乱。web

    鉴于此,咱们决定换一个策略。react-native

    咱们决定单独封装一个图片组件在外面,就单纯的接收每个图片的资源地址,显示图片应分配的长宽大小。而在封装的图片组件内部,作单独的加载逻辑判断。为每个被分配到的资源文件作判断和渲染。

    这样的话,就把一个相似集合的问题剥开分多个任务去分别处理了。

    在组件的内部,咱们能够根据onLoad和onError来为这单个图片的显示作相应的处理。

    // ImageChild.js
    
    // 初始化状态
       state = {
            loadStatus: 'pending',
            imageVis: false,
        };
    
    // 资源图片加载成功
       handleImageLoaded() {
            this.setState({
                loadStatus: 'success',
            })
        }
    
    // 资源图片加载失败
        handleImageErrored() {
            this.setState({
                loadStatus: 'error',
            })
        }
    
    
        render() {
            const {loadStatus, imageVis} = this.state;
            const {imgNum, loadImgUrl} = this.props;
    
            // 资源图片加载失败时显示默认的错误图片
            if (loadStatus === 'error') {
                return (
                    <Image
                        source={require('../images/iv_default.png')}
                        style={{
                            width: window.width / imgNum.length - 5,
                            height: window.width * 0.32,
                            margin: 2
                        }}
                        resizeMode={'cover'}
                    />
                )
            }
    
            return (
                <View>
                    <Image
                        style={{
                            width: window.width / imgNum.length - 5,
                            height: window.width * 0.32,
                            margin: 2
                        }}
                        source={{uri: loadImgUrl}}
                        resizeMode={'cover'}
                        onProgress={this.handleImageProgress}
                        onLoad={this.handleImageLoaded.bind(this)}
                        onError={this.handleImageErrored.bind(this)}
                    />
    
    // 正在加载时显示loading动画
                    {
                        !imageVis &&
                        <View style={{
                            width: window.width / imgNum.length - 5,
                            height: window.width * 0.32,
                            alignItems: 'center',
                            justifyContent: 'center',
                            marginTop: -window.width * 0.32,
                            margin: 2
                        }}>
                            <ActivityIndicator
                                color={'#666'}
                                size={'large'}
                            />
                        </View>
                    }
                </View>
            )
        }
    
    复制代码

    3、点击任意图片逻辑

    那么到目前为止,基本的布局和加载显示就完成了。咱们已经作了一个最多9个每行3个按分配的3等距显示,加载时显示loading,加载完成显示图片,加载错误显示默认错误图片的组件。剩下的就是点击一个加载完成的图片,单独显示该图片详情(占满屏幕),左右滑动可浏览相邻图片,再次点击图片还原的功能。

    对于点击图片查看详情,咱们这里实现的逻辑有:

    1. 点击图片时,背景出现遮罩同时图片放大到屏幕对应尺寸。
    2. 点击图片时,只有该图片放大。
    3. 图片放大后的容器。

    当初,在开发设计时,咱们想到能够用点击图片事件来改变背景颜色,同时按比例放大图片(到屏幕宽高尺寸)。可是,在开发过程的demo尝试时,发现这是一种很差的实现方式(只是单纯的一个实现思路,没有考虑到性能)。在ReactNative的render()中是致命的。由于这样的连续渲染极容易形成卡顿的感受。

    因此,我采用了modal的策略。

    当我点击其中一个图片的时候,弹出一个全屏的modal。把应用操做层提到最上面的同时,把下面的显示内容遮住。

    那么,咱们就能够在这个遮罩中显示咱们所点击的那个图片了。

    对于如何把图片放大到屏幕大小?而且保持原图片的宽高比例?这个地方实现的方法有不少种。我在这里参考了Androidpicasso源码的设计方式。

    先利用ImagegetSize()这个方法,将加载完成的图片的宽和高获取到,图片资源地址错误的给默认的宽高,顺序的暂存到一个数组中。而后将宽设定为100%(当前屏幕的宽度),根据比例和已经设定好的屏幕宽度求出对应比例下的高度。

    constructor(props) {
            super(props);
            this.state = {
                imgVis: false,
                visPage: 0,
                _imgHeight: [],
                copyImgSource: [], // 为图片的高度空间设置一个存储空间
                sortKey: [],
            };
        }
    
        componentWillMount() {
            const {imgSource} = this.props;
            imgSource.forEach((urlImg, key) => {
                Image.getSize(urlImg, (oWidth, oHeight) => {
                    this.state.copyImgSource.push(imgSource[key]);
                    // 求出加载成功图片的高度,而且把他们存在一个数组当中
                    this.state._imgHeight.push(Math.ceil(window.width * (oHeight / oWidth)));
                    this.state.sortKey.push(key);
                })
            })
        }
    复制代码

    4、点击后的大图查看排版和基本的过渡动画

    对于如何点击那个就能直接显示那个图片,而且在左右滑动的时候,咱们能够浏览相邻的图片。

    咱们考虑的思路是在外部用ScrollView封装一个相似ViewPager这样的组件,能够用来横向承载数组的容器。而后咱们在内部的对应到那个key的时候,就把单独对应的这个图片抽出来显示。

    咱们在外部的View中,把整个组件的排列方式设置为横向flexDirection: "row"在内部用Animated.View对容器中的图片作渲染。根据点击的key,乘以传过来的width值,来设置左边的POS距离。而后将要显示的这一组图片遍历的显示进去。

    1. Animated的应用
    2. panResponder的使用

    咱们在左右滑动的过程当中:当向左边滑动一个图片,右边那个挨着的图片(若是还存在)就会跟着显示出来。让咱们点击这个图片的时候,这个图片的放大和缩小的过程以及透明度的变化,都会给咱们在用户体验上有很大的不一样。咱们在这里尽可能最求较为丝滑和更为舒服的操做体验。

    因此,咱们给图片设置一组动画。包括放大缩小,透明度变化以及动画时间。

    翻页图片的浏览,少不了触摸滑动的配合。这里简单介绍一下panResponder的基本用法和对于Animated的配合。

    panResponder:它能够将多点触控操做协调成一个手势。它使得一个单点触摸能够接受更多的触摸操做,也能够用于识别简单的多点触摸手势。它提供了一个对触摸响应系统响应器的可预测包装。对于每个处理函数,它在原生事件以外提供了一个新的gestureState对象。

    对于panResponder的分析,请看另外一篇详细分析:ReactNative中触摸事件浅析

    当在页面上的滑动值dx > dy,也就是说横向移动的X轴的距离大于纵向移动的Y轴的距离的绝对值的时候,咱们认为成功触发了这个滑动,而且咱们根据当前滑动X轴的长度,动态的向POS添加这个长度,同时也在更新下一个图片的位置,并把动画的值设置到相应的上面。

    // ImgScrollPage.js
    // 设定默认值
        static propTypes = {
            initPage: PropTypes.number,
            blurredZoom: PropTypes.number,
            blurredOpacity: PropTypes.number,
            animationDuration: PropTypes.number,
            pageStyle: PropTypes.object,
            onImgPageChange: PropTypes.func,
            deltaDelay: PropTypes.number,
            children: PropTypes.array.isRequired
        };
    
    
        static defaultProps = {
            initPage: 0,
            blurredZoom: 1,
            blurredOpacity: 0.8,
            animationDuration: 150,
            deltaDelay: 0,
            onImgPageChange: () => {
            }
    
        };
    
        state = {
            width: 0,
            height: 0
        };
    
       /** * 获取当前页面前面的总长度 * @param pageNb * @returns {number} * @private */
        _getPosForPage(pageNb) {
            return -pageNb * this._imgSizeInterval;
        }
    
    	/** * 动态获取当前显示页面的大小 * @param offset * @param diff * @returns {number} * @private */
        _getPageForOffset(offset, diff) {
            let boxPos = Math.abs(offset / this._imgSizeInterval);
            let index;
    
            if (diff < 0) {
                index = Math.ceil(boxPos);
            } else {
                index = Math.floor(boxPos);
            }
    
            if (index < 0) {
                index = 0;
            } else if (index > this.props.children.length - 1) {
                index = this.props.children.length - 1;
            }
            return index;
        }
    
    //panResponder预设
      componentWillMount() {
            this._panResponder = PanResponder.create({
                onStartShouldSetPanResponder: (evt, gestureState) => {
                    const dx = Math.abs(gestureState.dx);
                    const dy = Math.abs(gestureState.dy);
                    return (dx > this.props.deltaDelay && dx > dy);
                },
                onStartShouldSetPanResponderCapture: (evt, gestureState) => false,
                onMoveShouldSetPanResponder: (evt, gestureState) => {
                    const dx = Math.abs(gestureState.dx);
                    const dy = Math.abs(gestureState.dy);
                    return (dx > this.props.deltaDelay && dx > dy);
                },
                onMoveShouldSetPanResponderCapture: (evt, gestureState) => false,
    
                onPanResponderGrant: (evt, gestureState) => {
                },
                onPanResponderMove: (evt, gestureState) => {
                    let suffix = "x";
                    this.state.pos.setValue(this._lastPos + gestureState["d" + suffix]);
                },
                onPanResponderTerminationRequest: (evt, gestureState) => true,
                onPanResponderRelease: (evt, gestureState) => {
                    let suffix = "x";
                    this._lastPos += gestureState["d" + suffix];
                    let page = this._getPageForOffset(this._lastPos, gestureState["d" + suffix]);
                    this.animateToPage(page);
                },
                onPanResponderTerminate: (evt, gestureState) => {
                },
                onShouldBlockNativeResponder: (evt, gestureState) => true
            });
        }
    
        /** * 滑动下一页时的变化效果 加载新页图片的高度和滑动到的位置 * @param width * @param height * @private */
        _scrollNextPage = (width, height) => {
            this._imgPageSize = width;
            this._imgSizeInterval = width;
    
            let initPage = this.props.initPage || 0;
            if (initPage < 0) {
                initPage = 0;
            } else if (initPage >= this.props.children.length) {
                initPage = this.props.children.length - 1;
            }
    
            this._currentPage = initPage;
            this._lastPos = this._getPosForPage(this._currentPage);
    
            let viewsScale = [];
            let viewsOpacity = [];
            for (let i = 0; i < this.props.children.length; ++i) {
                viewsScale.push(new Animated.Value(i === this._currentPage ? 1 : this.props.blurredZoom));
                viewsOpacity.push(new Animated.Value(i === this._currentPage ? 1 : this.props.blurredOpacity));
            }
    
            this.setState({
                width,
                height,
                pos: new Animated.Value(this._getPosForPage(this._currentPage)),
                viewsScale,
                viewsOpacity
            });
        };
    
      /** * 为滑动添加动画效果 * @param page */
        animateToPage = (page) => {
            let animations = [];
            if (this._currentPage !== page) {
                animations.push(
                    Animated.timing(this.state.viewsScale[page], {
                        toValue: 1,
                        duration: this.props.animationDuration
                    })
                );
    
                animations.push(
                    Animated.timing(this.state.viewsOpacity[page], {
                        toValue: 1,
                        duration: this.props.animationDuration
                    })
                );
    
                animations.push(
                    Animated.timing(this.state.viewsScale[this._currentPage], {
                        toValue: this.props.blurredZoom,
                        duration: this.props.animationDuration
                    })
                );
    
                animations.push(
                    Animated.timing(this.state.viewsOpacity[this._currentPage], {
                        toValue: this.props.blurredOpacity,
                        duration: this.props.animationDuration
                    })
                );
            }
    
            let toValue = this._getPosForPage(page);
    
            animations.push(
                Animated.timing(this.state.pos, {
                    toValue: toValue,
                    duration: this.props.animationDuration
                })
            );
    
            Animated.parallel(animations).start();
    
            this._lastPos = toValue;
            this._currentPage = page;
            this.props.onImgPageChange(page);
        };
    
    
     render() {
            const {width, height} = this.state;
         // 经过宽和高的值简单判断是否为最后一张(或者第一张)
            if (!width && !height) {
                return (
                    <View style={{flex: 1}}>
                        <View
                            style={styles.orgNoPage}
                            onLayout={evt => {
                                let width = evt.nativeEvent.layout.width;
                                let height = evt.nativeEvent.layout.height;
                                this._scrollNextPage(width, height);
                            }}
                        />
                    </View>
                );
            }
    
            let containerStyle = {
                flex: 1,
                left: this.state.pos,
                paddingLeft: 0,
                paddingRight: 0,
                flexDirection: "row"
            };
            let imgPageStyle = {
                width: this._imgPageSize,
                marginRight: 0
            };
    
            return (
                <View style={styles.orgScrollView}>
                    <Animated.View
                        style={containerStyle}
                        {...this._panResponder.panHandlers}
                    >
                        {
                            this.props.children.map((imgSource, key) => {
                                return (
                                    <Animated.View
                                        key={key}
                                        style={[{
                                            opacity: this.state.viewsOpacity[key],
                                            transform: [{scaleY: this.state.viewsScale[key]}]
                                        }, imgPageStyle, this.props.pageStyle]}
                                    >
                                        {imgSource}
                                    </Animated.View>
                                );
                            })
                        }
                    </Animated.View>
    
                </View>
            );
        }
    复制代码

    5、点击时值的传递

    其实到此为止,咱们想要的大部份内容都已经出来了。只须要把这几个效果作相应的拼合就能够了。事实上,仍是有不少事情要作的,咱们这里好像是只作了简易的demo介绍。

    好比说:一些单击时值的传递,和尽可能把不一样的事情交给不一样的组件去办。从我介绍的这个结构来看,其实整个组件是由俩大部分组成的。

    1. 图片排版组件
    2. 查看详情的浏览组件

    其中,在排版组件中还作了进一步的封装。把一组图片数据交给单独的组件去处理,细分到加载和显示是否是成功。根据数据的状况来肯定排版的结构。

    其次,在图片浏览中咱们根据数据量的大小和点击图片传入的key值,来分配前端内容长度和后续补充内容的长度。同时根据panResponder的相关方法来动态的改变这俩个值,动态的改变先后段长度以实现滑动浏览的效果。同时把这些值同步到Animated中以实现更好的交互体验。

    这个过程当中,有些基本值的传递和滑动时一些数据的改变,动态的分配这些值的状况。大致来讲都是比较简单的。

    前半结构主要是布局,后半结构主要是数据处理和触发值的控制。

  • 大致总结

    1. 总体结构仍是比较简单的,所有用的都是ReactNative官方组件及其Api。主要仍是对于其中一些组件的使用和相关方法的使用。ImageModalAnimated.ViewPanResponder等组件的配合使用以及简单组件的封装思想,把一些可重复的事情剥离出去单独操做,把数据在各个组件间传递拆解拼装整合
    2. 一些初始化值的设定,和一些个别(特殊)状况的兼容。在这个开源组件中,我单独的写了一个简单的工具类,把一些经常使用的判断方法和抽离验证方法放在里面,这其实就是封装。咱们把和业务场景相关性不大的逻辑单独剥离出来。把视角重点放在业务层面上,代码的逻辑就会变的更加清晰明了。
  • 一些不足

    1. 这个组件是我写的第一个开源组件,本身在设备上运行了几回,并无发现什么问题。其实做为一个严谨的软件工程师,这显然是不严谨的。由于一个库没有一个合理的测试是有风险的,咱们在用的时候,尤为是在商业化项目做为第三方开源组件引入的话,风险仍是比较高的。因此,接下来的时间我会写一些测试用例。
    2. 这个组件是基于IOS的设备开发的,在android上尚未测试,可能会存在一些问题。首先是对于gif动图类的内容,须要手动去添加相应的库
    3. 设计之初的一个想法是点击后出现图片详情页面(图片放大),多点触控能够放大和缩小。这个功能暂时没有作。这个计划在将后的完善中会把这个作上去。
    4. 这篇文章一个是为了记录这个组件的开发过程,同时也想公布出来个人一个基本结构让后来者学习产考,让行业大佬指正批评。以更加完善个人技术能力,在往后的开发上更加严谨。
  • 一些感谢

本文篇幅较长,感谢各位读者阅读到此。还有诸多错误和不足之处,还请各位大佬纰漏和批评指出。必定虚心求学,完善本身的不足。有什么交流想法能够评论留言,也能够添加微信咱们交流沟通。感谢~ 🙏

  • 原文地址

记一个 'Image 图片浏览器' 开源组件的开发流程

  • 一个二维码

相关文章
相关标签/搜索