嘗試寫個UC瀏覽器(堆疊檢視A)

子不語卿發表於2017-12-29

背景:快過年了,問題那個多呀,最近手都敲出老繭了,上班打個卡都要識別幾分鐘,不知道身為程式猿的你是不是有同樣的感受。唉,不說了,老子名下還有200多個bug...

額滴親孃呀
既然前面已經吹了兩次逼(佈局篇主頁互動篇),我們得繼續水呀,來吧,今天我們談談堆疊檢視的實現,先看美圖:

1.進入與退出

進入與退出.gif
2.滑動
滑動.gif
3.新增頁面
新增.gif
4.刪除頁面
刪除.gif
參照UC正統做的,不像會被打臉,哈哈。如果你喜歡這個專案,可以在github上留下屬於你的記號☺github.com/zibuyuqing/…

這個功能的實現還是比較複雜的,比起探探等那種選項卡樣式,需要注意的地方多了不少,本文我將帶你一步一步實現這些效果,當然方案是個人想出來的,不止這一種哈。 敘事路線如下:

(1)元件結構設計

(2)定義堆疊檢視

(3)豎向手勢處理

(4)橫向手勢處理

(5)增刪頁面邏輯

(6)過渡動畫實現

由於篇幅有限,這篇文章就先講到豎向手勢處理吧(文章寫得很詳細,我試了一下,如果寫完會很長,已經寫了一個下午了)。

元件結構設計

結構設計

模式:介面卡模式,觀察者模式;參考模型:RecyclerView

堆疊檢視的本質是一個View容器,是一個List,基於此,我們要設計這個元件,自然會想到介面卡模式,android中View容器用的最多的是Listview,RecyclerView還有繼承自AdapterView的GridView等,當然我最喜歡用的還是RecyclerView,那我們就參考這逼動手搞個簡單的。主要組成部分:StackView(堆疊檢視容器,類RecyclerView),ViewHolder,Adapter。

結構.png

具體實現

Adapter

參考RecyclerView的Adapter,寫一個抽象類,然後思考一下我們需要哪些方法:

(1)建立view

(2)獲取資料或者子項的個數

(3)獲取繫結view的型別

(4)監聽資料變化

實現了這些方法,一個基本的Adapter就實現了,我們看一下程式碼:

      public static abstract class Adapter<VH extends ViewHolder> {
        // 被觀察者
        private final AdapterDataObservable observable = new AdapterDataObservable();

        // 建立view
        public VH createView(ViewGroup parent, int viewType) {
            VH holder = onCreateView(parent, viewType);
            holder.itemViewType = viewType;
            return holder;
        }

        protected abstract VH onCreateView(ViewGroup parent, int viewType);

        // 繫結view
        public void bindViewHolder(VH holder, int position) {
            onBindViewHolder(holder, position);
        }

        protected abstract void onBindViewHolder(VH holder, int position);

        // 獲取item count
        public abstract int getItemCount();

        public final void notifyDataSetChanged() {
            observable.notifyDataChanged();
        }
        
        public int getItemViewType(int position) {
            return 0;
        }
        // 註冊觀察者
        public void registerObserver(AdapterDataObserver observer) {
            observable.registerObserver(observer);
        }

    }
複製程式碼

這裡出現了一個定義的資料目標AdapterDataObservable,看看它的實現

    public static class AdapterDataObservable extends Observable<AdapterDataObserver> {
        // mObservers 觀察者集合
        public boolean hasObservers() {
            return !mObservers.isEmpty();
        }
        // 通知各位觀察者
        public void notifyDataChanged() {
            for (AdapterDataObserver observer : mObservers) {
                observer.onChanged();
            }
        }
    }
複製程式碼

嘿嘿嘿...觀察者模式,那有了資料目標(被觀察者),觀察者自然也少不了

    public static abstract class AdapterDataObserver {
        public void onChanged() {

        }
    }

    private class ViewDataObserver extends AdapterDataObserver {
        @Override
        public void onChanged() {
            refreshViews();
        }
    }
複製程式碼

這裡做的比較暴力,有資料更新,立馬更新全部view,至於單個更新view的方法,留給各位實現吧。

ViewHolder

用過RecyclerView的猴子應該都會寫吧

    public static abstract class ViewHolder {
        public View itemView;
        public int itemViewType;
        int position;

        public ViewHolder(View view) {
            itemView = view;
        }

        public Context getContext() {
            return itemView.getContext();
        }
    }
複製程式碼
Bean

這裡我要根據實際業務思考我們的資料模型了,開啟正統手機UC瀏覽器,進入頁面管理介面,我的鈦金狗眼發現一個頁面由標題、網頁預覽圖、網站圖示組成,為了區分不同頁,我們還需要給每個頁設定一個Key,好,資料模型搭建起來了

public class UCPager {
    private String title;
    private int websiteIcon;//網站圖示更合理的是在雲端下載,為了方便,我先使用本地的
    private Bitmap pagerPreview;
    private int key;
    public UCPager(String title, int websiteIcon, Bitmap pagerPreview,int key) {
        this.title = title;
        this.websiteIcon = websiteIcon;
        this.pagerPreview = pagerPreview;
        this.key = key;
    }
...
}
複製程式碼
Item tamplate

起初我以為每一個頁面都是一個UCRootView(根佈局),想想如果那樣,也太耗費記憶體了吧,於是我再次開啟頁面管理介面,用DDMS看一下某個頁面的佈局

頁面佈局.png
臥槽,so easy ,原來每個頁面都是張圖片,好吧,依葫蘆畫瓢搞一個xml就可以了。

<?xml version="1.0" encoding="utf-8"?>
<com.zibuyuqing.ucbrowser.widget.stackview.UCPagerView
    xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="match_parent">
    <ImageView
        android:id="@+id/ivPagePreview"
        android:scaleType="centerCrop"
        android:layout_gravity="center"
        android:src="@drawable/test_uc_screen"
        android:layout_width="match_parent"
        android:layout_height="match_parent" />
    <RelativeLayout
        android:id="@+id/rlPageHead"
        android:background="@color/windowBg"
        android:layout_width="match_parent"
        android:layout_height="@dimen/dimen_48dp">
        <ImageView
            android:id="@+id/ivWebsiteIcon"
            android:padding="12dp"
            android:scaleType="centerCrop"
            android:src="@drawable/ic_home"
            android:layout_width="@dimen/dimen_48dp"
            android:layout_height="match_parent" />
        <TextView
            android:id="@+id/tvPagerUC"
            android:textSize="20dp"
            android:layout_centerVertical="true"
            android:layout_toRightOf="@id/ivWebsiteIcon"
            android:text="UC"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content" />
        <ImageView
            android:id="@+id/ivPageClose"
            android:layout_alignParentRight="true"
            android:padding="14dp"
            android:src="@drawable/ic_close"
            android:layout_width="@dimen/dimen_48dp"
            android:layout_height="match_parent" />
    </RelativeLayout>
</com.zibuyuqing.ucbrowser.widget.stackview.UCPagerView>
複製程式碼

定義堆疊檢視

設計思想

在我眼裡,一切介面的變化都可以用比例來控制,在這個系列的第一篇文章《嘗試寫個UC瀏覽器(佈局篇)》中,我們介紹的UCRootView裡的滑動處理就是基於比例(rate)搞得,然後我們在《互動篇》 將這個rate用的淋漓盡致,感興趣的小夥伴可以看看哈。既然可以,那就幹吧。在StackView裡我給這個比例起了一個響亮的名字:Progress!使用者在進入介面時初始化progress,在滑動時更新progress並用它來檢測是否overscroll,使用者在手指抬起後用當前progress和目標progress對比,然後自動滑到對應位置就可以了。

差異實現

那我們就要思考了,為什麼不同頁可以出現在不同位置?TranslationY!為什麼不同頁面大小不同?Scale!為什麼能出現這種炫酷效果?TranslationY + Scale!

計算 TranslationY

    /**
     * 計算view的TransY,首先根據參照進度,來算出各個view的偏移進度,然後偏移進度4次方來擴大差異
     * 最後在得出目標TransY
     * mViewMinTop 為view最高能滑動到的地方
     * mViewMaxTop 為view最低能滑動到的地方
     * @param i view 的索引值
     * @param progress 參考進度
     */
    int calculateProgress2TransY(int i,float progress) {
        return (int) (mViewMinTop +
                Math.pow(calculateViewProgress(i,progress),4) * (mViewMaxTop - mViewMinTop));
    }

    int calculateProgress2TransZ(float progress) {
        return (int) (mViewMinTop + Math.pow(progress, 3) * (200));
    }

複製程式碼

這裡用到了4次方,是我經過無數次(也就4次)試驗所確定的最佳效果,如果你不來這個4次方,效果是這樣的:

嘗試寫個UC瀏覽器(堆疊檢視A)
每個頁面的間距都一樣,如果再縮小這個間距,再加上陰影,探探的卡片樣式就形成了。 下面我們看看calculateViewProgress()這個方法

    /**
     * 用於計算每個view的滑動進度
     * @param index view的位置
     * @param progress 參考進度
     * @return
     */
    private float calculateViewProgress(int index,float progress) {
        return PROGRESS_STEP * index + progress;
    }
複製程式碼

根據view的index依次增加PROGRESS_STEP(0.2f,當然你可以換成其他數值),然後加上我們的參考比例,就是我們所需要的,是不是很簡單。

計算 Scale
    /**
     * 計算scale
     * mViewMaxScale 為view最大scale
     * mViewMinScale 為view最小的scale
     * @param i view 的位置
     * @param progress 參考進度
     */
    float calculateProgress2Scale(int i,float progress) {
        float scaleRange = (mViewMaxScale - mViewMinScale);
        return mViewMinScale + (calculateViewProgress(i,progress) * scaleRange);
    }
複製程式碼

這裡也使用到了calculateViewProgress()這個方法,但是沒有4次方。

設定子View屬性

有了計算數值,我們需要讓他們和view關聯起來,對view屬性的設定也是堆疊檢視最核心的方法之一,是一切效果的基礎,我們來看看

    private void layoutChildren() {
        int childCount = getChildCount();
        float progress;
        float transY;
        float transZ;
        View child;
        mChildTouchRect = new Rect[childCount];// 子view的觸控範圍
        Log.e(TAG,"layoutChildren :: layoutChildren :: mLayoutState =:" + mLayoutState);
        for (int i = 0; i < childCount; i++) {

            child = getChildAt(i);

            // 設定點選範圍
            Rect rect = new Rect();
            child.getHitRect(rect);
            mChildTouchRect[i] = rect;

            // 根據 mLayoutState 決定要更新哪些view的屬性,在刪除頁面時用到
            switch (mLayoutState){
                case LAYOUT_PRE_ACTIVE:
                    if(i > mActivePager){
                        continue;
                    }
                    break;
                case LAYOUT_AFTER_ACTIVE:
                    if(i < mActivePager){
                        continue;
                    }
            }
            progress = getScrollP();
            transY = calculateProgress2TransY(i,progress);
            transZ = calculateProgress2TransZ(progress);
            Log.e(TAG, "layoutChildren :: progress =:" + progress + ",transY =:" + transY);
            translateViewY(transY, child);
            //translateViewZ(transZ, child);
            scaleView(calculateProgress2Scale(i,progress), child);
        }
        invalidate();
    }
複製程式碼

mChildTouchRect是一個Rect的陣列,記錄每個view的繪製範圍,這個是我們在處理手勢時識別子view的參照,很重要,隨著view屬性的變化,我們要實時更新這個陣列。 分別對view的TranslationY和Scale進行設定,我們就可以實現以下效果:

最終效果.png
自此,一個靜態的堆疊檢視搭建成功,下面我們要讓它動起來。

豎向手勢處理

我們之前一直在圍繞progress說事,那麼這個progress是怎來的呢?答案正如我們在《佈局篇》講述的一樣——通過滑動的距離與目標距離間的比值確定。

滑動檢測

我們假設要在本層處理事件,並且進行滑動,順序如下:在onInterceptTouchEvent方法中,判斷是否攔截——如果自動滑動的動畫在執行或者手指移動距離超過我們規定的閾值,則返回true;如果為true,我們將在本層處理事件,這個時候執行onTouchEvent。因為onInterceptTouchEvent和onTouchEvent兩個方法實現差不多,我們看一個就行了,下面是onTouchEvent方法部分程式碼:

            case MotionEvent.ACTION_DOWN: {
                // 記錄初始觸控點
                mInitialMotionX = mLastMotionX = (int) ev.getX();
                mInitialMotionY = mLastMotionY = (int) ev.getY();
                mActivePointerId = ev.getPointerId(0);
                // 如果已經在滾動,停止他
                stopScroller();
                // 初始化速度追蹤器
                initOrResetVelocityTracker();
                mVelocityTracker.addMovement(ev);
                // Disallow parents from intercepting move events
                break;
            }
            //處理多指
            case MotionEvent.ACTION_POINTER_DOWN: {
                final int index = ev.getActionIndex();
                mActivePointerId = ev.getPointerId(index);
                mLastMotionX = (int) ev.getX(index);
                mLastMotionY = (int) ev.getY(index);
                break;
            }
            case MotionEvent.ACTION_MOVE: {
                if (mActivePointerId == INVALID_POINTER) break;
                Log.e(TAG, "onTouchEvent :: ACTION_MOVE = ");
                mVelocityTracker.addMovement(ev);

                int activePointerIndex = ev.findPointerIndex(mActivePointerId);
                int x = (int) ev.getX(activePointerIndex);
                int y = (int) ev.getY(activePointerIndex);
                int yTotal = Math.abs(y - (int) mInitialMotionY);
                float deltaP = mLastMotionY - y;
                if (!mIsScrolling) {
                    if (yTotal > mTouchSlop) {
                        mIsScrolling = true;
                    }
                }
                if (mIsScrolling) {
                    // mTotalMotionY 就是我們滑動的總距離
                    if (isOverPositiveScrollP()) {
                        // calculateDamping() 為計算阻尼的方法,即當overscroll時,實現越來越難滑的效果
                        mTotalMotionY -= deltaP *(calculateDamping());
                    } else {
                        mTotalMotionY -= deltaP;
                    }
                    // 更新view
                    doScroll();
                }

                mLastMotionX = x;
                mLastMotionY = y;
                break;
            }
複製程式碼

當使用者手指離開螢幕(ACTION_UP、ACTION_POINTER_UP)或者取消動作(ACTION_CANCEL)時,我們應該怎麼做呢?

(1)如果是多指中的一個手指離開螢幕,更新觸控點資訊

(2)如果手指移動速度很大,讓它飛一會兒

(3)從當前位置滑動到我們規定的合理位置

(4)重置滑動狀態

          case MotionEvent.ACTION_UP: {
                mVelocityTracker.computeCurrentVelocity(1000, mMaximumVelocity);
                int velocity = (int) mVelocityTracker.getYVelocity(mActivePointerId);
                // 速度很大時,執行scroller.fling()方法,讓介面跑一會兒
                if (mIsScrolling && (Math.abs(velocity) > mMinimumVelocity)) {
                    fling(velocity);
                } else {
                    // 滑動到目標位置
                    scrollToPositivePosition();
                }
                // 重置滑動狀態
                resetTouchState();
                Log.e(TAG, "onTouchEvent :: mIsOverScroll =:" + mIsOverScroll);
                break;
            }
            // 更新觸控資訊
            case MotionEvent.ACTION_POINTER_UP: {
                int pointerIndex = ev.getActionIndex();
                int pointerId = ev.getPointerId(pointerIndex);
                if (pointerId == mActivePointerId) {
                    // Select a new active pointer id and reset the motion state
                    final int newPointerIndex = (pointerIndex == 0) ? 1 : 0;
                    mActivePointerId = ev.getPointerId(newPointerIndex);
                    mLastMotionX = (int) ev.getX(newPointerIndex);
                    mLastMotionY = (int) ev.getY(newPointerIndex);
                    mVelocityTracker.clear();
                }
                break;
            }
            case MotionEvent.ACTION_CANCEL: {
                scrollToPositivePosition();
                resetTouchState();
                break;
            }
複製程式碼

滑動到指定位置我們將在overscroll檢測中探討,這裡先說讓頁面飛一會兒,很顯然,使用Scroller.fling()方法:

    /**
     * 如果我們手指離開螢幕時滑動速度很快,讓view飛一會,X方向忽略
     * @param velocity
     */
    public void fling(int velocity) {
        mScroller.fling(
                0,
                (int) mTotalMotionY,
                0,
                 velocity,
                0,
                0,
                Integer.MIN_VALUE,
                Integer.MAX_VALUE);
        invalidate();
    }
複製程式碼

這裡scroller倒是滑了,但是我們需要更新view呀!!!咋整?重寫computeScroll()

    @Override
    public void computeScroll() {
        Log.e(TAG, "computeScroll :: mIsOverScroll :" + mIsOverScroll);
        if (mScroller.computeScrollOffset()) {
            if(mIsOverScroll){
                // 如果 overscroll 滑動到指定位置
                scrollToPositivePosition();
            } else {
                if(mScroller.isFinished()){
                    scrollToPositivePosition();
                }
                mTotalMotionY = mScroller.getCurrY();
                doScroll();
            }
        }
        super.computeScroll();
    }

複製程式碼

doScroll方法看到兩次了,這貨幹了兩件事——檢測是否overscroll和更新view屬性(我們之前介紹的layoutChildren())。

    /**
     * 執行滾動
     */
    private void doScroll() {
        computeScrollProgress(); // 判斷是否overScroll
        layoutChildren(); // 改變每個view的屬性
    }
    /**
     *
     * @return 是否超過規定位置
     */
    private boolean computeScrollProgress() {
        if (getChildCount() <= 0) {
            return false;
        }
        mIsOverScroll = false;
        mScrollProgress = getScrollRate(); //更新progress
        mIsOverScroll = (mScrollProgress > mMaxScrollP || mScrollProgress < mMinScrollP);
        return mIsOverScroll;
    }
複製程式碼

終於看到那個神通廣大的progress了,噹噹噹當....

    /**
     * @return 移動的距離和目標距離的比
     */
    private float getScrollRate() {
        float topSpace = mViewMaxTop; // mViewMaxTop就是螢幕高度
        return mTotalMotionY / topSpace;
    }
複製程式碼

ok,我們縷縷:整個流程為:判斷是否攔截事件——》消費事件——》即時計算滑動距離——》檢測是否OverScroll——》更新progress——》更新view。當然這裡我只介紹了滑動的情況,例舉了部分程式碼,如果看的有點懵或者想深究,請:https://github.com/zibuyuqing/UCBrowser

overscroll檢測

UC瀏覽器在滑動過程中如果超過某一限定範圍,會越來越吃力,而且當滑動到底部或者頂部時,繼續滑動後會回退到規定位置,這裡就有用到overscroll檢測了。

檢測是否over

首先說明,當我們的子view數量變化時,為了保證參考progress不做調整。這個時候我們需要更新滑動範圍

    /**
     * 刪除頁面後,我們會更新滑動的範圍
     */
    private void updateScrollProgressRange(){
        mMinScrollP = BASE_MIN_SCROLL_P - (getChildCount() - 2) * PROGRESS_STEP;
        mMaxScrollP = BASE_MAX_SCROLL_P;
        mMinPositiveScrollP = mMinScrollP + PROGRESS_STEP * 0.25f;
        mMaxPositiveScrollP = mMaxScrollP - PROGRESS_STEP * 0.75f;
        Log.e(TAG,"updateScrollProgressRange ::mMinScrollP =:" + mMinScrollP +",mMaxScrollP =:" + mMaxScrollP);
    }
複製程式碼

裡面的常量是我經過千萬次試驗確定的,哈哈,也是累呀。有了滑動範圍,我們怎麼檢測我們是否超過這個範圍呢?

mIsOverScroll = (mScrollProgress > mMaxScrollP || mScrollProgress < mMinScrollP);
複製程式碼

哈哈,吐血了,原來那麼簡單。細心的猴子能看到上面的程式碼有mMinPositiveScrollP和mMaxPositiveScrollP兩個變數,這兩個值是是否阻止使用者滑動的界值,當使用者滑動超過這兩個值時,滑動會越來越費力;當使用者手指離開螢幕後,子view自動滾動到相應位置(第一段程式碼是在OnTouchEvent裡)。

                if (mIsScrolling) {
                    // mTotalMotionY 就是我們滑動的總距離
                    if (isOverPositiveScrollP()) {
                        // calculateDamping() 為計算阻尼的方法,即當overscroll時,實現越來越難滑的效果
                        mTotalMotionY -= deltaP *(calculateDamping());
                    } else {
                        mTotalMotionY -= deltaP;
                    }
                    // 更新view
                    doScroll();
                }
複製程式碼
   boolean isOverPositiveScrollP(){
        return (mScrollProgress > mMaxPositiveScrollP || mScrollProgress < mMinPositiveScrollP);
    }
複製程式碼
    /**
     * 計算阻尼,當超過我們設定的位置時,讓使用者在滑動的時候感到“吃力”
     * @return
     */
    private float calculateDamping(){
        float damping = (1.0f - Math.abs(mScrollProgress - getPositiveScrollP()) * 5);
        Log.e(TAG,"calculateDamping :: damping = :" + damping);
        return damping;
    }
複製程式碼
自動滾動到指定位置

當使用者手指離開螢幕後,如果overscroll,我們將自動滾動到指定位置

(1)獲取目標progress

    /**
     * 根據滑動的進度來判斷手指釋放後需要自動回滾的目標進度
     */
    float getPositiveScrollP() {
        if (mScrollProgress < mMinPositiveScrollP) {
            return mMinPositiveScrollP;
        } else if(mScrollProgress > mMaxPositiveScrollP){
            return mMaxPositiveScrollP;
        }
        return mScrollProgress;
    }
複製程式碼

(2)定義回滾動畫

/**
     * 手指釋放後,如果滑動到的位置不是我們的期望位置(比如滑過了),需要自動回滾
     * @param curScroll 當前進度
     * @param newScroll 目標進度
     * @param postRunnable 滾到目標位置後需要執行的動作
     */
    void animateScroll(float curScroll, float newScroll, final Runnable postRunnable) {
        // Finish any current scrolling animations
        stopScroller();
        // 根據屬性“scrollP”定義滑動動畫
        mScrollAnimator = ObjectAnimator.ofFloat(this, "scrollP", curScroll, newScroll);
        //  動畫時間
        mScrollAnimator.setDuration(mDuration);
        // 插值器
        mScrollAnimator.setInterpolator(mLinearOutSlowInInterpolator);
        mScrollAnimator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
            @Override
            public void onAnimationUpdate(ValueAnimator valueAnimator) {
                
                // 更新 progress
                setScrollP((Float) valueAnimator.getAnimatedValue());
            }
        });
        mScrollAnimator.addListener(new AnimatorListenerAdapter() {
            @Override
            public void onAnimationEnd(Animator animation) {
                if (postRunnable != null) {
                    postRunnable.run();
                }
                mScrollAnimator.removeAllListeners();
            }
        });
        mScrollAnimator.start();
    }
複製程式碼
    public void setScrollP(float progress) {
        Log.e(TAG, "rate =:" + progress);
        mTotalMotionY = calculateProgress2Y(progress);// 將progress轉化為移動距離
        mScrollProgress = progress;
        layoutChildren();
    }
複製程式碼

裡面有個progress到mTotalMotionY的轉化,之前我竭力保證progress在view增刪後不跳變,在這裡看到了效果

    /**
     * 根據我們的參考進度還原滑動的距離
     * @param progress
     * @return
     */
    private float calculateProgress2Y(float progress) {
        return progress * mViewMaxTop;
    }
複製程式碼

是不是很簡單,哈哈,萌出一臉血O(∩_∩)O。

(3)執行回滾

     /**
     * 手指離開螢幕後滾到目標位置
     */
    private void scrollToPositivePosition() {
        Log.e(TAG, "scrollToPositivePosition mScrollProgress =:" + mScrollProgress);
        float curScroll = getScrollP();
        float positiveScrollP = getPositiveScrollP();
        // 當前progress和目標progress不一樣時執行
        if(Float.compare(curScroll,positiveScrollP) != 0) {
            animateScroll(curScroll, getPositiveScrollP(), new Runnable() {
                @Override
                public void run() {
                    // 動畫結束後重置滑動狀態
                    resetTouchState();
                }
            });
            invalidate();
        }
    }
複製程式碼

成功
寫到這,我們的豎向滑動算是介紹完了,程式碼很多,大家可以有選擇的看,如果你感覺不過癮,歡迎 github

轉載請註明:juejin.im/post/5a4442…

附:下篇我們將實現橫向滑動刪除頁面功能,包括點選與點刪、空白頁檢測、轉場動畫實現(兩天內完成)

下下篇我們將探討拖拽檢視的實現,效果如下

拖拽.gif
我能告訴你這個更麻煩嗎?考慮的問題很多很多,一個人做有點捉襟見肘呀,相當於實現了半個Launcher,不要問我為什麼知道,我就是做Launcher的。歡迎感興趣的同學一起開發(* ̄︶ ̄)。

系列文章:

嘗試寫個UC瀏覽器(佈局篇)

嘗試寫個UC瀏覽器(主頁互動篇)

專案地址: github.com/zibuyuqing/…

相關文章