美文网首页Android技术知识Android知识Android开发
尝试写个UC浏览器(堆叠视图A)

尝试写个UC浏览器(堆叠视图A)

作者: zibuyuqing | 来源:发表于2017-12-27 19:16 被阅读0次

    背景:快过年了,问题那个多呀,最近手都敲出老茧了,上班打个卡都要识别几分钟,不知道身为程序猿的你是不是有同样的感受。唉,不说了,老子名下还有200多个bug...

    额滴亲娘呀
    既然前面已经吹了两次逼(布局篇主页交互篇),咱得继续水呀,来吧,今天我们谈谈堆叠视图的实现,先看美图:
    1.进入与退出
    进入与退出.gif
    2.滑动
    滑动.gif
    3.添加页面
    添加.gif
    4.删除页面
    删除.gif
    参照UC正统做的,不像会被打脸,哈哈。如果你喜欢这个项目,可以在github上留下属于你的记号☺https://github.com/zibuyuqing/UCBrowser

    这个功能的实现还是比较复杂的,比起探探等那种选项卡样式,需要注意的地方多了不少,本文我将带你一步一步实现这些效果,当然方案是个人想出来的,不止这一种哈。
    叙事路线如下:
    (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次方,效果是这样的:



    每个页面的间距都一样,如果再缩小这个间距,再加上阴影,探探的卡片样式就形成了。
    下面我们看看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
    转载请注明:https://www.jianshu.com/p/5a57ba857d95
    附:下篇我们将实现横向滑动删除页面功能,包括点选与点删、空白页检测、转场动画实现(两天内完成)
    下下篇我们将探讨拖拽视图的实现,效果如下
    拖拽.gif
    我能告诉你这个更麻烦吗?考虑的问题很多很多,一个人做有点捉襟见肘呀,相当于实现了半个Launcher,不要问我为什么知道,我就是做Launcher的。欢迎感兴趣的同学一起开发(* ̄︶ ̄)。

    系列文章:
    尝试写个UC浏览器(布局篇)
    尝试写个UC浏览器(主页交互篇)

    项目地址:
    https://github.com/zibuyuqing/UCBrowser

    相关文章

      网友评论

        本文标题:尝试写个UC浏览器(堆叠视图A)

        本文链接:https://www.haomeiwen.com/subject/kphiwxtx.html