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

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

  • 最终实现效果

  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"的策略来做。

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

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

    所以,我们需要稍稍更换一个策略。

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

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

    // 初始化数据
    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>
        )
    }
    复制代码
    二、加载时的样式和加载错误图片的替换

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

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

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

    鉴于此,我们决定换一个策略。

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

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

    在组件的内部,我们可以根据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>
            )
        }
    
    复制代码
    三、点击任意图片逻辑

    那么到目前为止,基本的布局和加载显示就完成了。我们已经做了一个最多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);
                })
            })
        }
    复制代码
    四、点击后的大图查看排版和基本的过渡动画

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

    我们考虑的思路是在外部用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>
            );
        }
    复制代码
    五、点击时值的传递

    其实到此为止,我们想要的大部分内容都已经出来了。只需要把这几个效果做相应的拼合就可以了。事实上,还是有很多事情要做的,我们这里好像是只做了简易的demo介绍。

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

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

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

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

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

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

  • 大体总结

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

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

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

  • 原文地址

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

  • 一个二维码

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值