記一個 '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 圖片瀏覽器' 開源組件的開發流程

  • 一個二維碼

相關文章
相關標籤/搜索