Android TabLayout

前言

很久很久没写过源码解析了,不是自己没有看了,只是没有记录了,却发现不记录的话,似懂非懂,时间久了就忘得差不多了,用到了还是得再学一遍,忍住提笔一篇 TabLayout 源码学习。

Hello World

依赖

添加 support design 包

implementation 'com.android.support:design:27.1.1'
xml

添加一个 TabLayout 就可以了

<?xml version="1.0" encoding="utf-8"?>
<android.support.constraint.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context=".TabLayoutActivity">
    
    <android.support.design.widget.TabLayout
        android:id="@+id/tab_layout"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"/>
</android.support.constraint.ConstraintLayout>
MainActivity.java

import android.support.design.widget.TabLayout;
import android.support.v7.app.AppCompatActivity;
import android.os.Bundle;

public class TabLayoutActivity extends AppCompatActivity {

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_tab_layout);

        TabLayout mTabLayout = findViewById(R.id.tab_layout);
        // 添加 tab item
        mTabLayout.addTab(mTabLayout.newTab().setText("TAB1"));
        mTabLayout.addTab(mTabLayout.newTab().setText("TAB2"));
        mTabLayout.addTab(mTabLayout.newTab().setText("TAB3"));
        mTabLayout.addTab(mTabLayout.newTab().setText("TAB4"));
    }
}
效果
Screenshot_1550732046.png

源码学习

其实,实现这样一个布局并不难,让我们来看看里面所有的内容

前世今生

继承自 HorizontalScrollView 因为他支持滚动

public class TabLayout extends HorizontalScrollView 

TabLayout 支持两种模式,一种是固定的,一种是可滚动的(tab 太多,一屏显示不下,可使用这种模式,否则默认为平分)

    /**
     * Scrollable tabs display a subset of tabs at any given moment, and can contain longer tab
     * labels and a larger number of tabs. They are best used for browsing contexts in touch
     * interfaces when users don’t need to directly compare the tab labels.
     *
     * @see #setTabMode(int)
     * @see #getTabMode()
     */
    public static final int MODE_SCROLLABLE = 0;

    /**
     * Fixed tabs display all tabs concurrently and are best used with content that benefits from
     * quick pivots between tabs. The maximum number of tabs is limited by the view’s width.
     * Fixed tabs have equal width, based on the widest tab label.
     *
     * @see #setTabMode(int)
     * @see #getTabMode()
     */
    public static final int MODE_FIXED = 1;

    /**
     * @hide
     */
    @RestrictTo(LIBRARY_GROUP)
    @IntDef(value = {MODE_SCROLLABLE, MODE_FIXED})
    @Retention(RetentionPolicy.SOURCE)
    public @interface Mode {}

Tab 的位置有两种,一种是居中,一种是平分

    /**
     * Gravity used to fill the {@link TabLayout} as much as possible. This option only takes effect
     * when used with {@link #MODE_FIXED}.
     *
     * @see #setTabGravity(int)
     * @see #getTabGravity()
     */
    public static final int GRAVITY_FILL = 0;

    /**
     * Gravity used to lay out the tabs in the center of the {@link TabLayout}.
     *
     * @see #setTabGravity(int)
     * @see #getTabGravity()
     */
    public static final int GRAVITY_CENTER = 1;

    /**
     * @hide
     */
    @RestrictTo(LIBRARY_GROUP)
    @IntDef(flag = true, value = {GRAVITY_FILL, GRAVITY_CENTER})
    @Retention(RetentionPolicy.SOURCE)
    public @interface TabGravity {}

居中模式

屏幕快照 2019-02-21 下午3.07.13.png

创建 Tab

使用代码创建 Tab

    public Tab newTab() {
        Tab tab = sTabPool.acquire();
        if (tab == null) {
            tab = new Tab();
        }
        tab.mParent = this;
        tab.mView = createTabView(tab);
        return tab;
    }

Tab 还使用了 Pool,还是挺细心的

    private static final Pools.Pool<Tab> sTabPool = new Pools.SynchronizedPool<>(16);
可滑动的指示条形图

自定义 ViewGroup

    private class SlidingTabStrip extends LinearLayout 
onMeasure

如果设置了 MODE_FIXED 和 GRAVITY_CENTER 则需要重新测量,目的就是让居中,每个 ITEM 的宽度都是一样的,而且等于最大的一个,如果一屏放得下则需要重新设置每个 ITEM 的大小,并且重新测量。如果发不下,那么侧设置GRAVITY_FILL

@Override
        protected void onMeasure(final int widthMeasureSpec, final int heightMeasureSpec) {
            super.onMeasure(widthMeasureSpec, heightMeasureSpec);

            if (MeasureSpec.getMode(widthMeasureSpec) != MeasureSpec.EXACTLY) {
                // HorizontalScrollView will first measure use with UNSPECIFIED, and then with
                // EXACTLY. Ignore the first call since anything we do will be overwritten anyway
                return;
            }
            // 重新测量
            if (mMode == MODE_FIXED && mTabGravity == GRAVITY_CENTER) {
                final int count = getChildCount();

                // First we'll find the widest tab
                int largestTabWidth = 0;
                for (int i = 0, z = count; i < z; i++) {
                    View child = getChildAt(i);
                    if (child.getVisibility() == VISIBLE) {
                        largestTabWidth = Math.max(largestTabWidth, child.getMeasuredWidth());
                    }
                }

                if (largestTabWidth <= 0) {
                    // If we don't have a largest child yet, skip until the next measure pass
                    return;
                }
                // 间隔
                final int gutter = dpToPx(FIXED_WRAP_GUTTER_MIN);
                boolean remeasure = false;
                // 一屏放得下
                if (largestTabWidth * count <= getMeasuredWidth() - gutter * 2) {
                    // If the tabs fit within our width minus gutters, we will set all tabs to have  
                    // the same width
                    for (int i = 0; i < count; i++) {
                        final LinearLayout.LayoutParams lp =
                                (LayoutParams) getChildAt(i).getLayoutParams();
                        if (lp.width != largestTabWidth || lp.weight != 0) {
                            lp.width = largestTabWidth;
                            lp.weight = 0;
                            remeasure = true;
                        }
                    }
                } else {
                    // If the tabs will wrap to be larger than the width minus gutters, we need
                    // to switch to GRAVITY_FILL
                    mTabGravity = GRAVITY_FILL;
                    updateTabViews(false);
                    remeasure = true;
                }

                if (remeasure) {
                    // Now re-measure after our changes
                    super.onMeasure(widthMeasureSpec, heightMeasureSpec);
                }
            }
        }

如何实现动画?

通过移动 IndicatorView

onLayout

mIndicatorAnimator 是动画辅助类,在 onLayout 中,非空而且正在运行则看取消,然后调用 animateIndicatorToPosition,动画调用,否则直接设置位置,不支持动画

    @Override                                                                               
    protected void onLayout(boolean changed, int l, int t, int r, int b) {                  
        super.onLayout(changed, l, t, r, b);                                                
                                                                                            
        if (mIndicatorAnimator != null && mIndicatorAnimator.isRunning()) {                 
            // If we're currently running an animation, lets cancel it and start a          
            // new animation with the remaining duration                                    
            mIndicatorAnimator.cancel();                                                    
            final long duration = mIndicatorAnimator.getDuration();                         
            animateIndicatorToPosition(mSelectedPosition,                                   
                    Math.round((1f - mIndicatorAnimator.getAnimatedFraction()) * duration));
        } else {                                                                            
            // If we've been layed out, update the indicator position                       
            updateIndicatorPosition();                                                      
        }                                                                                   
    } 

updateIndicatorPosition 首先获取选中的 View,然后看 mSelectionOffset 是否大于零,说明发生滚动,则需要重新计算新位置

    private void updateIndicatorPosition() {                                       
        final View selectedTitle = getChildAt(mSelectedPosition);                  
        int left, right;                                                           
                                                                                   
        if (selectedTitle != null && selectedTitle.getWidth() > 0) {               
            left = selectedTitle.getLeft();                                        
            right = selectedTitle.getRight();                                      
                                                                                   
            if (mSelectionOffset > 0f && mSelectedPosition < getChildCount() - 1) {
                // Draw the selection partway between the tabs                     
                View nextTitle = getChildAt(mSelectedPosition + 1);                
                left = (int) (mSelectionOffset * nextTitle.getLeft() +             
                        (1.0f - mSelectionOffset) * left);                         
                right = (int) (mSelectionOffset * nextTitle.getRight() +           
                        (1.0f - mSelectionOffset) * right);                        
            }                                                                      
        } else {                                                                   
            left = right = -1;                                                     
        }                                                                          
                                                                                   
        setIndicatorPosition(left, right);                                         
    } 
    // mIndicatorLeft 和 mIndicatorRight 控制了线的起始位置
    void setIndicatorPosition(int left, int right) {                
        if (left != mIndicatorLeft || right != mIndicatorRight) {   
            // If the indicator's left/right has changed, invalidate
            mIndicatorLeft = left;                                  
            mIndicatorRight = right;                                
            ViewCompat.postInvalidateOnAnimation(this);             
        }                                                           
    }                                                               

移动动画,移动间隔大的话,并不会从当前位置直接移动,而是跳跃一段距离再移动,通过 startLeft 和 startRight 控制,并且使用 ValueAnimator 来实现动画
这个不错,同意了 fraction 0-1 ,通过函数计算进度

setIndicatorPosition(                                           
        AnimationUtils.lerp(startLeft, targetLeft, fraction),   
        AnimationUtils.lerp(startRight, targetRight, fraction));
   void animateIndicatorToPosition(final int position, int duration) {              
       if (mIndicatorAnimator != null && mIndicatorAnimator.isRunning()) {          
           mIndicatorAnimator.cancel();                                             
       }                                                                            
                                                                                    
       final boolean isRtl = ViewCompat.getLayoutDirection(this)                    
               == ViewCompat.LAYOUT_DIRECTION_RTL;                                  
                                                                                    
       final View targetView = getChildAt(position);                                
       if (targetView == null) {                                                    
           // If we don't have a view, just update the position now and return      
           updateIndicatorPosition();                                               
           return;                                                                  
       }                                                                            
                                                                                    
       final int targetLeft = targetView.getLeft();                                 
       final int targetRight = targetView.getRight();                               
       final int startLeft;                                                         
       final int startRight;                                                        
                                                                                    
       if (Math.abs(position - mSelectedPosition) <= 1) {                           
           // If the views are adjacent, we'll animate from edge-to-edge            
           startLeft = mIndicatorLeft;                                              
           startRight = mIndicatorRight;                                            
       } else {                                                                     
           // Else, we'll just grow from the nearest edge                           
           final int offset = dpToPx(MOTION_NON_ADJACENT_OFFSET);                   
           if (position < mSelectedPosition) {                                      
               // We're going end-to-start                                          
               if (isRtl) {                                                         
                   startLeft = startRight = targetLeft - offset;                    
               } else {                                                             
                   startLeft = startRight = targetRight + offset;                   
               }                                                                    
           } else {                                                                 
               // We're going start-to-end                                          
               if (isRtl) {                                                         
                   startLeft = startRight = targetRight + offset;                   
               } else {                                                             
                   startLeft = startRight = targetLeft - offset;                    
               }                                                                    
           }                                                                        
       }                                                                            
       // 开始移动位置                                                                             
       if (startLeft != targetLeft || startRight != targetRight) {                  
           ValueAnimator animator = mIndicatorAnimator = new ValueAnimator();       
           animator.setInterpolator(AnimationUtils.FAST_OUT_SLOW_IN_INTERPOLATOR);  
           animator.setDuration(duration);                                          
           animator.setFloatValues(0, 1);                                           
           animator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {  
               @Override                                                            
               public void onAnimationUpdate(ValueAnimator animator) {              
                   final float fraction = animator.getAnimatedFraction();           
                   setIndicatorPosition(                                            
                           AnimationUtils.lerp(startLeft, targetLeft, fraction),    
                           AnimationUtils.lerp(startRight, targetRight, fraction)); 
               }                                                                    
           });                                                                      
           animator.addListener(new AnimatorListenerAdapter() {                     
               @Override                                                            
               public void onAnimationEnd(Animator animator) {                      
                   mSelectedPosition = position;                                    
                   mSelectionOffset = 0f;                                           
               }                                                                    
           });                                                                      
           animator.start();                                                        
       }                                                                            
   }                                                                                

onDraw 很简单

    @Override                                                                          
    public void draw(Canvas canvas) {                                                  
        super.draw(canvas);                                                            
                                                                                       
        // Thick colored underline below the current selection                         
        if (mIndicatorLeft >= 0 && mIndicatorRight > mIndicatorLeft) {                 
            canvas.drawRect(mIndicatorLeft, getHeight() - mSelectedIndicatorHeight,    
                    mIndicatorRight, getHeight(), mSelectedIndicatorPaint);            
        }                                                                              
    }                                                                                  

TabView

接下来再看看上面的内容

  class TabView extends LinearLayout {
        private Tab mTab;                  
        private TextView mTextView;        
        private ImageView mIconView;       
                                           
        private View mCustomView;          
        private TextView mCustomTextView;  
        private ImageView mCustomIconView; 
                                           
        private int mDefaultMaxLines = 2;  
  }

如何实现监听的,对每个 TabView 设置点击事件,重写了 performClick,其中调用 mTab.select,mTab 拥有 TabLayout 的引用,

    @Override                                               
    public boolean performClick() {                         
        final boolean handled = super.performClick();       
                                                            
        if (mTab != null) {                                 
            if (!handled) {                                 
                playSoundEffect(SoundEffectConstants.CLICK);
            }                                               
            mTab.select();                                  
            return true;                                    
        } else {                                            
            return handled;                                 
        }                                                   
    }                                                       
    /**                                                                             
     * Select this tab. Only valid if the tab has been added to the action bar.     
     */                                                                             
    public void select() {                                                          
        if (mParent == null) {                                                      
            throw new IllegalArgumentException("Tab not attached to a TabLayout");  
        }                                                                           
        mParent.selectTab(this);                                                    
    }                                                                                                                                   

tabLayout 回调事件,

    void selectTab(Tab tab) {                                                              
        selectTab(tab, true);                                                              
    }                                                                                      
                                                                                           
    void selectTab(final Tab tab, boolean updateIndicator) {                               
        final Tab currentTab = mSelectedTab;                                               
                                                                                           
        if (currentTab == tab) {                                                           
            if (currentTab != null) {                                                      
                dispatchTabReselected(tab);                                                
                animateToTab(tab.getPosition());                                           
            }                                                                              
        } else {                                                                           
            final int newPosition = tab != null ? tab.getPosition() : Tab.INVALID_POSITION;
            if (updateIndicator) {                                                         
                if ((currentTab == null || currentTab.getPosition() == Tab.INVALID_POSITION
                        && newPosition != Tab.INVALID_POSITION) {                          
                    // If we don't currently have a tab, just draw the indicator           
                    setScrollPosition(newPosition, 0f, true);                              
                } else {                                                                   
                    animateToTab(newPosition);                                             
                }                                                                          
                if (newPosition != Tab.INVALID_POSITION) {                                 
                    setSelectedTabView(newPosition);                                       
                }                                                                          
            }                                                                              
            if (currentTab != null) {                                                      
                dispatchTabUnselected(currentTab);                                         
            }                                                                              
            mSelectedTab = tab;                                                            
            if (tab != null) {                                                             
                dispatchTabSelected(tab);                                                  
            }                                                                              
        }                                                                                  
    }                                                                                      

移动 Tab,动画

    private void animateToTab(int newPosition) {                                        
        if (newPosition == Tab.INVALID_POSITION) {                                      
            return;                                                                     
        }                                                                               
                                                                                        
        if (getWindowToken() == null || !ViewCompat.isLaidOut(this)                     
                || mTabStrip.childrenNeedLayout()) {                                    
            // If we don't have a window token, or we haven't been laid out yet just dra
            // position now                                                             
            setScrollPosition(newPosition, 0f, true);                                   
            return;                                                                     
        }                                                                               
                                                                                        
        final int startScrollX = getScrollX();                                          
        final int targetScrollX = calculateScrollXForTab(newPosition, 0);               
                                                                                        
        if (startScrollX != targetScrollX) {                                            
            ensureScrollAnimator();                                                     
                                                                                        
            mScrollAnimator.setIntValues(startScrollX, targetScrollX);                  
            mScrollAnimator.start();                                                    
        }                                                                               
                                                                                        
        // Now animate the indicator                                                    
        mTabStrip.animateIndicatorToPosition(newPosition, ANIMATION_DURATION);          
    }                                                                                   

计算移动的距离,让选中的 tab 位于中间位置,由于 Android ScrollView 默认不会滚动超出边界,所以如果到达边界也不会继续滚动了

    private int calculateScrollXForTab(int position, float positionOffset) {                                  
        if (mMode == MODE_SCROLLABLE) {                                                                       
            final View selectedChild = mTabStrip.getChildAt(position);                                        
            final View nextChild = position + 1 < mTabStrip.getChildCount()                                   
                    ? mTabStrip.getChildAt(position + 1)                                                      
                    : null;                                                                                   
            final int selectedWidth = selectedChild != null ? selectedChild.getWidth() : 0;                   
            final int nextWidth = nextChild != null ? nextChild.getWidth() : 0;                               
                                                                                                              
            // base scroll amount: places center of tab in center of parent                                   
            int scrollBase = selectedChild.getLeft() + (selectedWidth / 2) - (getWidth() / 2);                
            // offset amount: fraction of the distance between centers of tabs                                
            int scrollOffset = (int) ((selectedWidth + nextWidth) * 0.5f * positionOffset);                   
                                                                                                              
            return (ViewCompat.getLayoutDirection(this) == ViewCompat.LAYOUT_DIRECTION_LTR)                   
                    ? scrollBase + scrollOffset                                                               
                    : scrollBase - scrollOffset;                                                              
        }                                                                                                     
        return 0;                                                                                             
    }                                                                                                         

ViewPager

其实很简单,就是给 ViewPager 添加一个 OnPageChangeListener 就行了,代码也很简单,在 onPageScrolled 中改变 指示条 的位置,在 onPageSelected 中改变 选中状态

    public static class TabLayoutOnPageChangeListener implements ViewPager.OnPageChangeListener {
        private final WeakReference<TabLayout> mTabLayoutRef;
        private int mPreviousScrollState;
        private int mScrollState;

        public TabLayoutOnPageChangeListener(TabLayout tabLayout) {
            mTabLayoutRef = new WeakReference<>(tabLayout);
        }

        @Override
        public void onPageScrollStateChanged(final int state) {
            mPreviousScrollState = mScrollState;
            mScrollState = state;
        }

        @Override
        public void onPageScrolled(final int position, final float positionOffset,
                final int positionOffsetPixels) {
            final TabLayout tabLayout = mTabLayoutRef.get();
            if (tabLayout != null) {
                // Only update the text selection if we're not settling, or we are settling after
                // being dragged
                final boolean updateText = mScrollState != SCROLL_STATE_SETTLING ||
                        mPreviousScrollState == SCROLL_STATE_DRAGGING;
                // Update the indicator if we're not settling after being idle. This is caused
                // from a setCurrentItem() call and will be handled by an animation from
                // onPageSelected() instead.
                final boolean updateIndicator = !(mScrollState == SCROLL_STATE_SETTLING
                        && mPreviousScrollState == SCROLL_STATE_IDLE);
                tabLayout.setScrollPosition(position, positionOffset, updateText, updateIndicator);
            }
        }

        @Override
        public void onPageSelected(final int position) {
            final TabLayout tabLayout = mTabLayoutRef.get();
            if (tabLayout != null && tabLayout.getSelectedTabPosition() != position
                    && position < tabLayout.getTabCount()) {
                // Select the tab, only updating the indicator if we're not being dragged/settled
                // (since onPageScrolled will handle that).
                final boolean updateIndicator = mScrollState == SCROLL_STATE_IDLE
                        || (mScrollState == SCROLL_STATE_SETTLING
                        && mPreviousScrollState == SCROLL_STATE_IDLE);
                tabLayout.selectTab(tabLayout.getTabAt(position), updateIndicator);
            }
        }

        void reset() {
            mPreviousScrollState = mScrollState = SCROLL_STATE_IDLE;
        }
    }
小结

基本上看完了,但对于一些细节,滚动边界问题还没有深刻的理解,只知道大概的逻辑

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 216,193评论 6 498
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 92,306评论 3 392
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 162,130评论 0 353
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 58,110评论 1 292
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 67,118评论 6 388
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 51,085评论 1 295
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 40,007评论 3 417
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 38,844评论 0 273
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 45,283评论 1 310
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 37,508评论 2 332
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 39,667评论 1 348
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 35,395评论 5 343
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 40,985评论 3 325
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 31,630评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,797评论 1 268
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 47,653评论 2 368
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 44,553评论 2 352

推荐阅读更多精彩内容

  • 1 前言 在上一篇 Android TabLayout系列之属性 中我们介绍了TabLayout的属性,同时也给出...
    joker_fu阅读 11,890评论 0 8
  • 使用滚动的标签指示器和滑动的内容页面,是手机应用经常出现的一种设计风格,常见的比较出名的应用有:微信(首页)、网易...
    亦枫阅读 73,478评论 14 99
  • 四川又地震了。上一次是2008年,这一次是2017年。 我又有一位挚爱的亲人去世了。上一次是2008年,这一次是2...
    刘泊含阅读 339评论 1 2
  • 准备工作 安装好 Nginx (注意编译安装 Nginx 时要把 SSL 模块添加上,否则不能配置 https,编...
    Sixpence_阅读 270评论 0 0
  • 吹了一天的野风 满身土味 坐进炎热的教室 忘了喝水 望着红皮的课本 不知提谁 写下烂俗的文字 空留疲惫
    陸離luly阅读 155评论 0 0