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

一、元發表於2019-03-04

前言

  • 由於業務需要做成類似騰訊課堂課程詳情滾動的效果,考慮到後面有可能有新的呈現方式,RN提供的元件沒有這種滾動控制元件,不如自己封裝,其實去年已經寫了一篇但是寫的比較亂,週末花了點時間重寫梳理下做的東西。

效果圖.gif

  • 專案地址 在這裡,如果有好的意見歡迎提 issue或pr。

開始

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

騰訊課堂視訊.gif

  • 咋一看介面感覺有點複雜,其實簡化來說,這個介面可以看成tab元件+scroll元件。哲學上說,要抓好主要矛盾與次要矛盾,這個問題的主要矛盾是scroll元件實現,也就是最外層的RNFixScrollView。

分解圖.png

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

  • 跑起來執行後發現的一個嚴重的問題是,如果Tab導航控制元件的內容區域存在ScrollView或者ListView時,無法滾動,只有最外層可以滾動,也就是手勢滾動被攔截了?

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

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

分析

為了解決上面的問題,我們需要了解幾個關鍵點。

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

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

尋找內層滾動容器,一開始是認為遞迴尋找可見的ScrollView例項(Android中介面控制元件是一種樹形結構),通過Hierarchy Viewer工具發現這三個都是可見的,隨後對比三個ScrollView屬性發現其在螢幕上的LocationOnScreenX座標不同,如果當前滾動容器顯示則等於0。

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

正所謂知己知彼,百戰不殆,看看Android觸控事件型別有哪些?我們想下玩手機的時候手指的情況:落下手指,抬起手指,移動手指是三種基本的操作,其實也是3種觸控事件,分別代表著MotionEvent.ACTION_DOWN,MotionEvent.ACTION_UP,MotionEvent.ACTION_MOVE

簡單來說,如下圖所示:觸控事件發生後,如果事件的座標處於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元件

原生上要做的事
  • 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();
    }
複製程式碼
  • 綜合上面的已知條件,只需要找出幾種臨界情況:

1.RNFixScrolView已到底部&&向上滑:不攔截

2.RNFixScrolView未到底部&&向上滑:攔截

3.RNFixScrolView未到底部&&向下滑&&子ScrollView已到頂部:攔截

4.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 螢幕手勢滑動

相關文章