闲着无聊,撸个微信导航栏的动画吧!
程序员文章站
2022-06-26 13:11:38
/ 今日科技快讯 /9月10日,按照去年的约定,马云正式辞去阿里巴巴董事局主席,由现任集团CEO张勇(逍遥子)接任,马云将退休。这不是马云的第一次“退休”。实际上......
/ 今日科技快讯 /
9月10日,按照去年的约定,马云正式辞去阿里巴巴董事局主席,由现任集团CEO张勇(逍遥子)接任,马云将退休。这不是马云的第一次“退休”。
实际上马云的第一次退休可以追溯到2006年11月,在引入职业经理人后,他把公司总裁的位置让给卫哲,第二年,在卫哲的努力下,阿里巴巴B2B在香港成功上市,发行价为13.5港元。
马云的第二次退休在2013年5月,马云辞任阿里巴巴集团CEO,由陆兆禧接任。48岁的马云在杭州黄龙体育场单膝跪地,向员工宣布:48岁以前,工作就是我的生活;48岁以后,生活就是我的工作,以后就拜托大家了。
而马云的第三次退休就是今天,马云辞去阿里巴巴董事局主席,阿里换帅。
/ 作者简介 /
本篇文章来自不惜留恋_的投稿,分享了他对微信导航栏动画的实现,相信会对大家有所帮助!同时也感谢作者贡献的精彩文章。
不惜留恋_的博客地址:
https://juejin.im/user/577e6c37165abd00554b2245
/ 开始 /
微信自发布以来,底部导航栏的动画一直让开发者津津乐道,而且伴随着版本更新,底部导航栏的动画也一直在改进。我最近在闲暇之余,看了下微信的底部导航栏动画,于是思考了下这个动画的原理,感觉非常有意思,于是写下这篇文章。
下图就是我实现的效果,大家可以对比下微信的效果,几乎可以以假乱真。
/ 动画过程 /
关于这个动画的过程,我刚开始了是瞅了老半天了,因为如果我们不了解动画的过程也是无从去实现了,所以动画过程很重要,这个动画其实有两个过程。
-
首先是默认图片的轮廓变色。
-
轮廓变色到一定程度后,整个图片出现了绿色的填充效果,也就是整个图片开始变绿,直到整个图片完全变为了绿色。其实这是两个图片的透明度变换的达成的效果。
/ 动画实现原理 /
首先我们从整体上看,滑动的页面可以用ViewPager实现,在滑动的过程中,通过监听ViewPager的滑动事件,可以获取一个滑动的比例值。
底部的导航栏的4个Tab可以用自定义一个View来实现,我把这个自定义的View叫做TabView。那么,在滑动的过程中,当前页面的TabView执行褪色动画,后一个页面执行变色动画。动画到底执行到哪一步,肯定就是由ViewPager的滑动比例值决定的。因此TabView需要一个接收动画进度比例值的方法来控制动画的程度。
/ 代码实现 /
俗话说得好,Talk is cheap, show me the code!。那我们就通过代码来实现我们之前的猜想吧,这肯定是一段非常激情的旅程!
由于不想篇幅过大,因此我省略了ViewPager的一些样板代码,因为这些属于基本功。如果不会用ViewPager,在网上随便搜索就是一大堆的文章,很轻松就掌握了。那么本文主要就是解决如何自定义这个TabView。
自定义View有很多方式,我相信很多人比我还懂。而我选择的是组合系统控件的方式来实现这个自定义View。那么可能有人问我,如果为了更好的绘制性能,能不能完全的自定义一个View来实现呢?这当然是可以的,学完本文你就可以做这个牛逼的操作。然而,这点绘制性能的提升,其实在现在的高配置的手机上是可以忽略的。那么为了开发效率,组合系统控件应该是首选。
/ 实现组合控件的布局 /
TabView需要的组合控件的布局如下。
// tab_layout.xml
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="40dp"
android:gravity="center_horizontal"
android:orientation="vertical">
<FrameLayout
android:layout_width="wrap_content"
android:layout_height="0dp"
android:layout_weight="1">
<ImageView
android:id="@+id/tab_image"
android:layout_width="wrap_content"
android:layout_height="match_parent" />
<ImageView
android:id="@+id/tab_image_top"
android:layout_width="wrap_content"
android:layout_height="match_parent" />
</FrameLayout>
<TextView
android:id="@+id/tab_title"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textSize="12sp" />
</LinearLayout>
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="40dp"
android:gravity="center_horizontal"
android:orientation="vertical">
<FrameLayout
android:layout_width="wrap_content"
android:layout_height="0dp"
android:layout_weight="1">
<ImageView
android:id="@+id/tab_image"
android:layout_width="wrap_content"
android:layout_height="match_parent" />
<ImageView
android:id="@+id/tab_image_top"
android:layout_width="wrap_content"
android:layout_height="match_parent" />
</FrameLayout>
<TextView
android:id="@+id/tab_title"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textSize="12sp" />
</LinearLayout>
布局的TextView肯定是用来显示标题的,然而还有两个ImageView,为何这样设计呢?这与我们动画的实现有关。
@+id/tab_image的ImageView在底部,它是用来显示一个默认的图片的,我称它为轮廓图,例如第一个页面的TabView的轮廓图如下。
我们需要对这个轮廓进行变色处理,大家可以观察一下动画的过程,第一个过程很显然是轮廓的变色。
@+id/tab_image_top的ImageView在上面,它是用来显示一个页面被选中后的图片,也是动画最终要显示的图片,例如第一个页面的TabView的选中图片如下。
现在来说明下如何用这个布局来实现动画。
-
首先所有的TabView都显示轮廓图,选中图都进行隐藏。如何隐藏呢,我选择使用透明度来隐藏选中图,因为整个动画过程有透明度的变换。
-
当滑动ViewPager的时候,TabView获取滑动的进度值,我们就让轮廓图的轮廓开始变色。那么怎么变色呢,有一个很方便的方法,就是Drawable.setTint()方法。这个方法的原理就是PorterDuff.Mode.DST_IN混合模式。如果大家有兴趣,可以去研究下原理。
-
当ViewPager滑动到一定距离的时候,如果松开手指,页面会自动滑动到下一个页面,这个比例值到底是多少呢?我暂时还没有考究,我假定是0.5吧。当滑动的比较超过0.5的时候,就要让轮廓图的透明度逐渐变是0,也就是慢慢地的看不见了,同时,选中图的透明度逐渐变为255,也是慢慢的清晰了。如此一来,就会出现轮廓图的整体颜色填充效果。
怎么样,实现思路是不是有点意思,那么我们来根据这个思路来实现这个自定义ViewTabView吧。
/ 实现TabView /
加载布局
既然有了布局,那么首先用在TabView的构造函数中来加载这个布局。
public TabView(Context context, @Nullable AttributeSet attrs) {
super(context, attrs);
// 加载布局
inflate(context, R.layout.tab_layout, this);
}
super(context, attrs);
// 加载布局
inflate(context, R.layout.tab_layout, this);
}
自定义属性与解析
为了更好的在XML布局中使用TabView,我为TabView抽取的自定义属性。
// res/values/tabview_attrs.xml
<?xml version="1.0" encoding="utf-8"?>
<resources>
<declare-styleable name="TabView">
<attr name="tabColor" format="color|integer" />
<attr name="tabImage" format="reference" />
<attr name="tabSelectedImage" format="reference" />
<attr name="tabTitle" format="string|reference" />
</declare-styleable>
</resources>
<?xml version="1.0" encoding="utf-8"?>
<resources>
<declare-styleable name="TabView">
<attr name="tabColor" format="color|integer" />
<attr name="tabImage" format="reference" />
<attr name="tabSelectedImage" format="reference" />
<attr name="tabTitle" format="string|reference" />
</declare-styleable>
</resources>
-
tabColor代表变色最终显示的颜色,这个颜色可以从选中图中用取色器获取。
-
tabImage代表默认显示的轮廓图。
-
tabSelectedImage代表选中后的图。
-
tabTitle代表要显示的标题。
有了这些自定义属性,那么在TabView中必须要解析这些自定义属性。
public TabView(Context context, @Nullable AttributeSet attrs) {
super(context, attrs);
// 加载布局
inflate(context, R.layout.tab_layout, this);
TypedArray a = context.obtainStyledAttributes(attrs, R.styleable.TabView);
for (int i = 0; i < a.getIndexCount(); i++) {
int attr = a.getIndex(i);
switch (attr) {
case R.styleable.TabView_tabColor:
// 获取标题和轮廓最终的着色
mTargetColor = a.getColor(attr, DEFAULT_TAB_TARGET_COLOR);
break;
case R.styleable.TabView_tabImage:
// 获取轮廓图
mNormalDrawable = a.getDrawable(attr);
break;
case R.styleable.TabView_tabSelectedImage:
// 获取选中图
mSelectedDrawable = a.getDrawable(attr);
break;
case R.styleable.TabView_tabTitle:
// 获取标题
mTitle = a.getString(attr);
break;
}
}
a.recycle();
}
super(context, attrs);
// 加载布局
inflate(context, R.layout.tab_layout, this);
TypedArray a = context.obtainStyledAttributes(attrs, R.styleable.TabView);
for (int i = 0; i < a.getIndexCount(); i++) {
int attr = a.getIndex(i);
switch (attr) {
case R.styleable.TabView_tabColor:
// 获取标题和轮廓最终的着色
mTargetColor = a.getColor(attr, DEFAULT_TAB_TARGET_COLOR);
break;
case R.styleable.TabView_tabImage:
// 获取轮廓图
mNormalDrawable = a.getDrawable(attr);
break;
case R.styleable.TabView_tabSelectedImage:
// 获取选中图
mSelectedDrawable = a.getDrawable(attr);
break;
case R.styleable.TabView_tabTitle:
// 获取标题
mTitle = a.getString(attr);
break;
}
}
a.recycle();
}
自定义属性解析完毕后,就需要给用这些属性值给控件进行初始化。View的onFinishInflate()方法代表布局加载完成,因此在这里获取控件,并进行初始化。
@Override
protected void onFinishInflate() {
super.onFinishInflate();
// 1.设置标题,默认着色为黑色
mTitleView = findViewById(R.id.tab_title);
mTitleView.setTextColor(DEFAULT_TAB_COLOR);
mTitleView.setText(mTitle);
// 2.设置轮廓图片,不透明,默认着色为黑色
mNormalImageView = findViewById(R.id.tab_image);
mNormalDrawable.setTint(DEFAULT_TAB_COLOR);
mNormalDrawable.setAlpha(255);
mNormalImageView.setImageDrawable(mNormalDrawable);
// 3.设置选中图片,透明,默认着色为黑色
mSelectedImageView = findViewById(R.id.tab_selected_image);
mSelectedDrawable.setAlpha(0);
mSelectedImageView.setImageDrawable(mSelectedDrawable);
}
protected void onFinishInflate() {
super.onFinishInflate();
// 1.设置标题,默认着色为黑色
mTitleView = findViewById(R.id.tab_title);
mTitleView.setTextColor(DEFAULT_TAB_COLOR);
mTitleView.setText(mTitle);
// 2.设置轮廓图片,不透明,默认着色为黑色
mNormalImageView = findViewById(R.id.tab_image);
mNormalDrawable.setTint(DEFAULT_TAB_COLOR);
mNormalDrawable.setAlpha(255);
mNormalImageView.setImageDrawable(mNormalDrawable);
// 3.设置选中图片,透明,默认着色为黑色
mSelectedImageView = findViewById(R.id.tab_selected_image);
mSelectedDrawable.setAlpha(0);
mSelectedImageView.setImageDrawable(mSelectedDrawable);
}
标题设置了一个默认颜色DEFAULT_TAB_COLOR,是黑色。同样,也为轮廓图的轮廓设置黑色。轮廓图的透明度初始为255,也就是完全可见,而选中图的透明度设置为0,也就是完全不可见。所有这一切就是动画的初始状态。
控制动画进度
在前面的讲解动画的原理的时候说到一个事情,TabView需要使用ViewPager滑动进度值来控制动画的进度,因此还要为TabView定义一个接收进度值的方法。
/**
* 根据进度值进行变色和透明度处理。
*
* @param percentage 进度值,取值[0, 1]。
*/
public void setXPercentage(float percentage) {
if (percentage < 0 || percentage > 1) {
return;
}
// 1. 颜色变换
int finalColor = evaluate(percentage, DEFAULT_TAB_COLOR, mTargetColor);
mTitleView.setTextColor(finalColor);
mNormalDrawable.setTint(finalColor);
// 2. 透明度变换
if (percentage >= 0.5 && percentage <= 1) {
// 原理如下
// 进度值: 0.5 ~ 1
// 透明度: 0 ~ 1
// 公式: percentage - 1 = (alpha - 1) * 0.5
int alpha = (int) Math.ceil(255 * ((percentage - 1) * 2 + 1));
mNormalDrawable.setAlpha(255 - alpha);
mSelectedDrawable.setAlpha(alpha);
} else {
mNormalDrawable.setAlpha(255);
mSelectedDrawable.setAlpha(0);
}
// 3. 更新UI
invalidateUI();
}
* 根据进度值进行变色和透明度处理。
*
* @param percentage 进度值,取值[0, 1]。
*/
public void setXPercentage(float percentage) {
if (percentage < 0 || percentage > 1) {
return;
}
// 1. 颜色变换
int finalColor = evaluate(percentage, DEFAULT_TAB_COLOR, mTargetColor);
mTitleView.setTextColor(finalColor);
mNormalDrawable.setTint(finalColor);
// 2. 透明度变换
if (percentage >= 0.5 && percentage <= 1) {
// 原理如下
// 进度值: 0.5 ~ 1
// 透明度: 0 ~ 1
// 公式: percentage - 1 = (alpha - 1) * 0.5
int alpha = (int) Math.ceil(255 * ((percentage - 1) * 2 + 1));
mNormalDrawable.setAlpha(255 - alpha);
mSelectedDrawable.setAlpha(alpha);
} else {
mNormalDrawable.setAlpha(255);
mSelectedDrawable.setAlpha(0);
}
// 3. 更新UI
invalidateUI();
}
在这个对外开放的接口中,首先我们要根据进度值来计算轮廓要使用的颜色。起始颜色是黑色,最终颜色是一个绿色,然后我们还有一个进度值,那么如何计算某个进度的对应的颜色值呢?其实在属性动画中有一个类,ArgbEvaluator,它提供了颜色的计算方法,代码如下。
public Object evaluate(float fraction, Object startValue, Object endValue) {
int startInt = (Integer) startValue;
float startA = ((startInt >> 24) & 0xff) / 255.0f;
float startR = ((startInt >> 16) & 0xff) / 255.0f;
float startG = ((startInt >> 8) & 0xff) / 255.0f;
float startB = ( startInt & 0xff) / 255.0f;
int endInt = (Integer) endValue;
float endA = ((endInt >> 24) & 0xff) / 255.0f;
float endR = ((endInt >> 16) & 0xff) / 255.0f;
float endG = ((endInt >> 8) & 0xff) / 255.0f;
float endB = ( endInt & 0xff) / 255.0f;
// convert from sRGB to linear
startR = (float) Math.pow(startR, 2.2);
startG = (float) Math.pow(startG, 2.2);
startB = (float) Math.pow(startB, 2.2);
endR = (float) Math.pow(endR, 2.2);
endG = (float) Math.pow(endG, 2.2);
endB = (float) Math.pow(endB, 2.2);
// compute the interpolated color in linear space
float a = startA + fraction * (endA - startA);
float r = startR + fraction * (endR - startR);
float g = startG + fraction * (endG - startG);
float b = startB + fraction * (endB - startB);
// convert back to sRGB in the [0..255] range
a = a * 255.0f;
r = (float) Math.pow(r, 1.0 / 2.2) * 255.0f;
g = (float) Math.pow(g, 1.0 / 2.2) * 255.0f;
b = (float) Math.pow(b, 1.0 / 2.2) * 255.0f;
return Math.round(a) << 24 | Math.round(r) << 16 | Math.round(g) << 8 | Math.round(b);
}
int startInt = (Integer) startValue;
float startA = ((startInt >> 24) & 0xff) / 255.0f;
float startR = ((startInt >> 16) & 0xff) / 255.0f;
float startG = ((startInt >> 8) & 0xff) / 255.0f;
float startB = ( startInt & 0xff) / 255.0f;
int endInt = (Integer) endValue;
float endA = ((endInt >> 24) & 0xff) / 255.0f;
float endR = ((endInt >> 16) & 0xff) / 255.0f;
float endG = ((endInt >> 8) & 0xff) / 255.0f;
float endB = ( endInt & 0xff) / 255.0f;
// convert from sRGB to linear
startR = (float) Math.pow(startR, 2.2);
startG = (float) Math.pow(startG, 2.2);
startB = (float) Math.pow(startB, 2.2);
endR = (float) Math.pow(endR, 2.2);
endG = (float) Math.pow(endG, 2.2);
endB = (float) Math.pow(endB, 2.2);
// compute the interpolated color in linear space
float a = startA + fraction * (endA - startA);
float r = startR + fraction * (endR - startR);
float g = startG + fraction * (endG - startG);
float b = startB + fraction * (endB - startB);
// convert back to sRGB in the [0..255] range
a = a * 255.0f;
r = (float) Math.pow(r, 1.0 / 2.2) * 255.0f;
g = (float) Math.pow(g, 1.0 / 2.2) * 255.0f;
b = (float) Math.pow(b, 1.0 / 2.2) * 255.0f;
return Math.round(a) << 24 | Math.round(r) << 16 | Math.round(g) << 8 | Math.round(b);
}
熟悉属性动画的应该知道,参数float fraction的取值范围为0.f到1.f,所以可以把这个方法拷贝过来使用。
计算出颜色值后,就可以对标题和轮廓图着色了。
第二步,按照之前说的动画原理,当滑动的进度达到0.5后,要对轮廓图和选中图进行透明度的变换。
那么首先我们得计算出某个进度对应的透明度。很明显,这是一道数学题,进度的变化范围是从0.5到1.0,透明度的变换取0到1.0(之后于乘以255即可得到实际的透明度)。透明度和进度的比例值是2,那么就可以得出一个公式alpha - 1 = (percentage - 1.0) * 2。有了这个公式,就可以算出任意进度值对应的透明度了。
这一切就绪后,我们就使出杀手锏了,更新UI,让系统进行重绘。
/ 与ViewPager联动 /
最重要的自定义View已经准备完毕,是时候来测试效果了。那么我们必须要知道如何获取ViewPager的滑动进度值了,我们可以为ViewPager设置滑动监听器。
mViewPager.addOnPageChangeListener(new ViewPager.SimpleOnPageChangeListener() {
public void onPageScrolled(int position, float positionOffset, int positionOffsetPixels) {
}
});
public void onPageScrolled(int position, float positionOffset, int positionOffsetPixels) {
}
});
参数float positionOffset就是一个进度值,但是这个进度值使用起来还是需要点小技巧的,我们先从源码中看下解释。
/**
* This method will be invoked when the current page is scrolled, either as part
* of a programmatically initiated smooth scroll or a user initiated touch scroll.
*
* @param position Position index of the first page currently being displayed.
* Page position+1 will be visible if positionOffset is nonzero.
* @param positionOffset Value from [0, 1) indicating the offset from the page at position.
* @param positionOffsetPixels Value in pixels indicating the offset from position.
*/
void onPageScrolled(int position, float positionOffset, int positionOffsetPixels);
* This method will be invoked when the current page is scrolled, either as part
* of a programmatically initiated smooth scroll or a user initiated touch scroll.
*
* @param position Position index of the first page currently being displayed.
* Page position+1 will be visible if positionOffset is nonzero.
* @param positionOffset Value from [0, 1) indicating the offset from the page at position.
* @param positionOffsetPixels Value in pixels indicating the offset from position.
*/
void onPageScrolled(int position, float positionOffset, int positionOffsetPixels);
从注释中可以看出,onPageScrolled方法是在滑动的时候调用,参数position代表当前显示的页面,这表解释很容易产生误解,其实无论是从左边往右边滑动,还是从右边往左边滑动,position始终代表左边的页面,因此position + 1始终代表右边的页面。
参数positionOffset代表滑动的进度值,并且还有很重要一点,大部分人都会忽略,如果参数positionOffset为非零值,表示右边的页面可见,也就是说,如果positionOffset的值是零,那么代表右边的页面是不可见的,这一点会在代码中体现出来。既然已经对参数有所了解,那么现在来看看实现。
public void onPageScrolled(int position, float positionOffset, int positionOffsetPixels) {
// 左边View进行动画
mTabViews.get(position).setXPercentage(1 - positionOffset);
// 如果positionOffset非0,那么就代表右边的View可见,也就说明需要对右边的View进行动画
if (positionOffset > 0) {
mTabViews.get(position + 1).setXPercentage(positionOffset);
}
}
// 左边View进行动画
mTabViews.get(position).setXPercentage(1 - positionOffset);
// 如果positionOffset非0,那么就代表右边的View可见,也就说明需要对右边的View进行动画
if (positionOffset > 0) {
mTabViews.get(position + 1).setXPercentage(positionOffset);
}
}
mTabViews是一个ArrayList,它保存了所有的TabView,我们页面中有四个TabView。mTabViews.get(posistion)获取的是滑动时左边的页面,mTabViews.get(position + 1)获取的就是右边的页面。
当从左边向右边滑动的时候,左边页面的positionOffset的值是从0到1的,此时我们需要左边的页面的TabView执行褪色动画。然而在我们设计的TabView中,进度值达到1的时候,执行的是变色动画,而不是褪色动画,因此左边页面的TabView的进度取值要改变下,取1 - positionOffset。那么右边的页面的进度取值自然就是positionOffset了。从右到左的滑动的原理其实与从左到右的滑动的原理是一样的,大家可以从Log中看出端倪。
然而,在为左边的TabView做动画的时候,我们一定要确保有右边的页面存在。我们前面讲解的时候说过,如果positionOffset为0的时候,右边的页面是不可见的,因此我们要做一些排除的动作,这在代码中有体现的。
/ 代码优化 /
ViewPager可以自动滑动到下一个页面的进度值临界点是多少?TabView需要这个临界点来控制透明度的变换。
TabView只能通过XML的属性来控制图片的显示,控制最终显色的颜色等等功能,其实这些可以通过代码动态控制,我们可以实现一个对外的接口。
如果大家是个精益求精的人,可以对这两点进行考究和实现。
/ 结束 /
本文把动画的原理,以及如何用代码实现这些原理讲解清楚了,这些都是关键部分。然而其它部分的代码我并没有给出。为了方便想查看demo的人,我把代码上传到 github。项目地址为:
https://github.com/buxiliulian/WeChatBottomNavigation
推荐阅读:
欢迎关注我的公众号
学习技术或投稿
长按上图,识别图中二维码即可关注
本文地址:https://blog.csdn.net/c10WTiybQ1Ye3/article/details/100773411