仿騰訊課堂固定滾動列表ReactNative組件

前言

  • 因爲業務須要作成相似騰訊課堂課程詳情滾動的效果,考慮到後面有可能有新的呈現方式,RN提供的組件沒有這種滾動控件,不如本身封裝,其實去年已經寫了一篇可是寫的比較亂,週末花了點時間重寫梳理下作的東西。

效果圖.gif

  • 項目地址 在這裏,若是有好的意見歡迎提 issue或pr。

開始

  • 咱們先來看下,騰訊課堂視頻播放詳情頁面是怎麼樣的?

騰訊課堂視頻.gif

  • 咋一看界面感受有點複雜,其實簡化來講,這個界面能夠當作tab組件+scroll組件。哲學上說,要抓好主要矛盾與次要矛盾,這個問題的主要矛盾是scroll組件實現,也就是最外層的RNFixScrollView。

分解圖.png

  • 說道這裏,我嘗試着寫了個測試js例子,最外面套一個ReactNative自帶的ScrollView並設置視頻播放控件的高度爲200Tab導航控件style={{height: windowHeight- 80}},那這樣滾動距離到120時,滾動條到底部了,視頻播發控件的區域距離屏幕頂部還有80。javascript

  • 跑起來運行後發現的一個嚴重的問題是,若是Tab導航控件的內容區域存在ScrollView或者ListView時,沒法滾動,只有最外層能夠滾動,也就是手勢滾動被攔截了?html

  • 一開始想兩種大的思路:一種是徹底靠JS層面,經過ScrollView暴露的API去實現,第二種是原生+JS,這裏涉及到幾個關鍵的東西,如何尋找Tab導航控件中的ScrollView或者ListView和控制手勢實現的效果 -- 外層滾動容器到頂部+手勢往上則通知內層滾動容器開始滾動;內層到頂部+手勢往下則通知外層開始滾動。java

  • 發現第一種方法在解決如何尋找子控件並判斷滾動狀態上沒有方法(多是我沒發現)以及性能上的考量,那就採用第二種方法。react

分析

爲了解決上面的問題,咱們須要瞭解幾個關鍵點。android

  • 一個是怎麼判斷手勢滑動以及外層滾動容器到底部和內層滾動到頂部?
  • 第二個是尋找滾動組件並通知內層滾動組件開始滾動?

所以,網上搜尋這兩個問題的相關資料和解決辦法,判斷是否到底部很容易搜到了,固然瞭解了其原理。另外,判斷手勢是往上滑仍是往下滑的問題放到後面說明。git

尋找內層滾動容器,一開始是認爲遞歸尋找可見的ScrollView實例(Android中界面控件是一種樹形結構),經過Hierarchy Viewer工具發現這三個都是可見的,隨後對比三個ScrollView屬性發現其在屏幕上的LocationOnScreenX座標不一樣,若是當前滾動容器顯示則等於0。github

剩下最後一個如何通知內層容器滾動呢?先賣個關子,在解決這個問題以前,咱們先來了解下Android中的View事件是如何傳遞的。react-native

正所謂知己知彼,百戰不殆,看看Android觸摸事件類型有哪些?咱們想下玩手機的時候手指的狀況:落下手指,擡起手指,移動手指是三種基本的操做,其實也是3種觸摸事件,分別表明着MotionEvent.ACTION_DOWN,MotionEvent.ACTION_UP,MotionEvent.ACTION_MOVEide

簡單來講,以下圖所示:觸摸事件發生後,若是事件的座標處於ViewGroup的管轄範圍,那麼首先調用ViewGroup的dispatchTouchEvent方法,而後其內部調用onInterceptTouchEvent()方法來判斷是否攔截該觸摸事件,若攔截該事件則調用ViewGroup的onTouchEvent()方法,不然的話,交給其子View的dispatchTouchEvent處理。image.png
具體能夠參考我之前寫的事件分發機制學習工具

回過頭來說外層滾動容器通知內層滾動,其實通知滾動至關於不攔截事件,那麼就是重寫 onInterceptTouchEvent方法並返回false。而這個方法會隨着手勢不斷調用,這時候聰明的你想到了啥?根據手觸摸屏幕的y座標差來判斷手勢往上仍是往下。手指滑動時會產生一系列觸摸事件,這裏有兩種狀況:說明下屏幕的左上角是座標原點,沿着右邊是x軸,左邊則是y軸。
① Down -> Move ... -> Move -> UP
② Down -> Move ->... -> Move

記錄Down觸摸事件的Y座標值做爲起始值,Move或者UP的Y座標值做爲末尾值,二者之差大於最小滑動值則說明向上滑,小於最小滑動值則說明向上滑(這裏簡化了條件,若是是實現OnGestureListener的話判斷滑動的話還有X軸滑動速度值和Y軸滑動速度值)。到這裏前面提的兩個問題都獲得解決了,下面開始真正上手了。

如何封裝RN組件

  • 參考 RN 0.51中文文檔,咱們須要作這些東西:

    原生上要作的事
  • 1.建立原生固定滾動控件
  • 2.建立管理滾動控件ViewManager的子類
  • 3.建立實現了ReactPackage接口的類

JavaScript上要作的事
  • 4.實現對應的JavaScript模塊

開始動手

1.建立原生固定滾動控件

根據前面的分析,咱們知道寫原生滾動控件主要是重寫控制攔截事件方法onInterceptTouchEvent,這裏先說明下咱們只須要判斷當前 Tab導航控件 存在 ScrollView 的話才進入咱們的邏輯進行攔截控制,不然按默認的邏輯。

  • 須要在 MotionEvent.ACTION_DOWN 事件中,經過前面分析的條件尋找第一個子 ScrollView ,代碼以下:
private ScrollView findScrollView(ViewGroup group) {
        if (group != null) {
            for (int i = 0, j = group.getChildCount(); i < j; i++) {
                View child = group.getChildAt(i);
                if (child instanceof ScrollView) {
                    //獲取view在整個屏幕中的座標若是x==0的話表明這個scrollview是正在顯示
                    int[] location = new int[2];
                    child.getLocationOnScreen(location);
                    System.out.print("locationx:" + location[0] + ",locationy:" + location[1]);
                    if (location[0] == 0)
                        return (ScrollView) child;
                    else
                        continue;

                } else if (child instanceof ViewGroup) {
                    ScrollView result = findScrollView((ViewGroup) child);
                    if (result != null)
                        return result;
                }
            }
        }
        return null;
    }
  • 聲明計算滑動手勢的兩個點 Down點(x1, y1) Move點(x2, y2),這樣出現兩種狀況:向上滑,向下滑

  • 在經過isAtBottom方法,判斷RNFixScrollView是否滑到底部。

public boolean isAtBottom() {
        return getScrollY() == getChildAt(getChildCount() - 1).getBottom() + getPaddingBottom() - getHeight();
    }
  • 綜合上面的已知條件,只須要找出幾種臨界狀況:
    RNFixScrolView已到底部&&向上滑:不攔截
    RNFixScrolView未到底部&&向上滑:攔截
    RNFixScrolView未到底部&&向下滑&&子ScrollView已到頂部:攔截
    RNFixScrolView已到底部&&向下滑&&子ScrollView未到頂部:不攔截,代碼以下:
@Override
    public boolean onInterceptTouchEvent(MotionEvent ev) {
        if (!mScrollEnabled) {
            return false;
        }

        int action = ev.getAction();
        if (action == MotionEvent.ACTION_DOWN) {
            //當手指按下的時候
            x1 = ev.getX();
            y1 = ev.getY();
            scrollView = findScrollView(this);
            isIntercept = false;
        }

        if ((action == MotionEvent.ACTION_MOVE) || (action == MotionEvent.ACTION_UP)) {
            //Tab導航控件是否存在ScrollView
            if (scrollView != null) {
                //當手指移動或者擡起的時候計算其值
                x2 = ev.getX();
                y2 = ev.getY();
                //判斷RNFixScrollView是否到底部 
                isbottom = isAtBottom();
                //向上滑動
                if (y1 - y2 > FLING_MIN_DISTANCE ) {
                    if (!isbottom) {
                        isIntercept = true;
                    } else {
                        isIntercept = false;
                    }
                    return isIntercept;
                } //向下滑動
                else if (y2 - y1 > FLING_MIN_DISTANCE ) {
                    int st = scrollView.getScrollY();
                    if (!isbottom) {
                        isIntercept = true;
                    } else {
                        if (st == 0) {
                            isIntercept = true;
                        } else {
                            isIntercept = false;
                        }
                    }
                    return isIntercept;
                }
            }
        }
        //不加的話 ReactScrollView滑動不了
        if (super.onInterceptTouchEvent(ev)) {
            NativeGestureUtil.notifyNativeGestureStarted(this, ev);
            ReactScrollViewHelper.emitScrollBeginDragEvent(this);
            mDragging = true;
            enableFpsListener();
            return true;
        }
        return false;
    }

以上代碼完成了第一步建立原生固定滾動控件主要邏輯。

2.建立管理滾動控件ViewManager的子類

簡單講下,copy RN自帶的ScrollViewManager 類,修改類名和其餘引用到ScrollViewManager 。另外注意修改字段,REACT_CLASS = "RNFixedScrollView",這個與JS的模塊的名字存在映射。

3.建立實現了ReactPackage接口的類並註冊

RNAppViewsPackage 類

public class RNAppViewsPackage implements ReactPackage {
    @Override
    public List<NativeModule> createNativeModules(
            ReactApplicationContext reactContext) {
        List<NativeModule> modules = new ArrayList<>();
        return modules;
    }

    @Override
    public List<ViewManager> createViewManagers(ReactApplicationContext reactContext) {
        return Arrays.<ViewManager>asList(
                new RNFixedScrollViewManager()
        );
    }
}

MainApplication類進行註冊

@Override
    protected List<ReactPackage> getPackages() {
      return Arrays.<ReactPackage>asList(
          new MainReactPackage(),
              new RNAppViewsPackage()
      );
    }
4.實現對應的JavaScript模塊

簡單講下,copy RN自帶ScrollViewJS的module,修改註釋上 providesModule 的值RNFixedScrollView以及導出原生模塊的名稱,與第二步的值存在映射。

if (Platform.OS === 'android') {
  nativeOnlyProps = {
    nativeOnly: {
      sendMomentumEvents: true,
    }
  };
  AndroidScrollView = requireNativeComponent(
    'RNFixedScrollView',
    (ScrollView: React.ComponentType<any>),
    nativeOnlyProps
  );
}

完成上面的內容後,能夠經過導入 import RNFixedScrollView from './modules/RNFixedScrollView',使用 RNFixedScrollView 控件

測試

爲了模擬這個界面,構建了下面的代碼,其中 ViewPagerPage組件是Tab導航控件,詳細代碼請轉到 github

  • 主頁面
<View style={styles.container}>
                <RNFixedScrollView showsVerticalScrollIndicator={false}>
                    <View style={{
                        backgroundColor: '#87cefa',
                        height: 200,
                    }}>
                    </View>
                    <ViewPagerPage style={{height: windowHeight- 80}}/>
                </RNFixedScrollView>
            </View>
  • Tab導航控件,第二個tab內容區域嵌套了 FlatList,其餘兩個則顯示文字。
import {StyleSheet, View, Text, Platform, Image, TouchableOpacity, Animated, Dimensions, FlatList} from 'react-native';
import React, {Component} from 'react';
import {PagerTabIndicator, IndicatorViewPager, PagerTitleIndicator, PagerDotIndicator} from 'rn-viewpager';

const windowWidth = Dimensions.get('window').width;
export default class ViewPagerPage extends Component {

    static title = '<FlatList>';
    static description = 'Performant, scrollable list of data.';

    state = {
        data: this.genItemData(20,0),
        debug: false,
        horizontal: false,
        filterText: '',
        fixedHeight: true,
        logViewable: false,
        virtualized: true,
    };

    genItemData(loadNum,counts){
       let items = [];
       for(let i=counts;i<counts+loadNum;i++){
           items.push({key:i});
        }
        return items;
    };

    _onEndReached(){
        this.setState((state) => ({
            data: state.data.concat(this.genItemData(10, state.data.length)),
        }));
    };

    render() {
        return (

                <IndicatorViewPager
                    style={[{backgroundColor: 'white', flexDirection: 'column-reverse'},this.props.style]}
                    indicator={this._renderTitleIndicator()}
                >
                    <View style={{backgroundColor: 'cornflowerblue'}}>
                        <Text>這裏是課程介紹</Text>
                    </View>
                    <View style={{backgroundColor: 'cadetblue'}}>
                        <FlatList
                            ItemSeparatorComponent={() => <View
                                style={{height: 1, backgroundColor: 'black', marginLeft: 0}}/>}
                            data={this.state.data}
                            onEndReached={this._onEndReached.bind(this)}
                            onEndReachedThreshold={0.2}
                            renderItem={({item}) => <View
                                style={{  justifyContent: 'center',height:40,alignItems:'center'}}><Text
                                style={{fontSize: 16}}>{"目錄"+item.key}</Text></View>}
                        />
                    </View>
                    <View style={{backgroundColor: '#1AA094'}}>
                        <Text>相關課程</Text>
                    </View>
                </IndicatorViewPager>

        );
    }

    _renderTitleIndicator() {
        return <PagerTitleIndicator style={{
            backgroundColor: 0x00000020,
            height: 48
        }} trackScroll={true} itemStyle={{width: windowWidth / 3}}
                                    selectedItemStyle={{width: windowWidth / 3}} titles={['詳情介紹', '目錄', '相關課程']}/>;
    }


}

總結

  • 從編寫玩這個組件在RN組件封裝仍是頗有收穫的,對於衡量使用不一樣的方案進行選擇也有了體會。
  • 調試代碼的時候須要技巧,經過註釋不一樣的代碼段,對於渲染不出界面是一種好的方法。
  • 弄清楚原理後編碼會少犯不少錯誤。

參考:

講講Android事件攔截機制
Android 屏幕手勢滑動

相關文章
相關標籤/搜索