本篇文章已授权微信公众号 guolin_blog (郭霖)独家发布
最近有一个需求,要实现一个像饿了么添加购物车的效果,下面是效果图
主要有以下几点
-
1 沉浸式状态栏
-
2 上下滑动的动画
-
3 添加减少的动画
-
4 贝塞尔曲线动画
-
5 底部购物车弹窗动画
-
6 购物车缓存
沉浸式状态栏
沉浸式状态栏网上有很多,通常都是放v19和v21的包,然后在最外层ViewGroup设置fitsSystemWindows = "true" ,这种方式我不太推荐,因为你每次都要写fitsSystemWindows 这个属性,可能你会说我抽取出来不就好了,ok,那有时候要的效果不是我想要的,就比如我图上的效果,那怎么办呢,其实,有时候,你只需要一点小小的bang助!!!,几行代码的事
values
values-v19
values-v21
重头戏来了,以ToolBar为例,动态设置ToolBar的高度,并且设置一个padding,top为状态栏的高度,搞定收工!
-
activity
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT) {
setHeight(mToolbar);
}
}
public void setHeight(View view) {
// 获取actionbar的高度
TypedArray actionbarSizeTypedArray = obtainStyledAttributes(new int[]{
android.R.attr.actionBarSize
});
float height = actionbarSizeTypedArray.getDimension(0, 0);
// ToolBar的top值
ViewGroup.MarginLayoutParams lp = (ViewGroup.MarginLayoutParams) view.getLayoutParams();
double statusBarHeight = getStatusBarHeight(this);
lp.height = (int) (statusBarHeight + height);
view.setPadding(0,(int) statusBarHeight,0, 0);
mToolbar.setLayoutParams(lp);
}
private double getStatusBarHeight(Context context) {
int result = 0;
int resourceId = context.getResources().getIdentifier("status_bar_height", "dimen",
"android");
if (resourceId > 0) {
result = context.getResources().getDimensionPixelSize(resourceId);
}
return result;
}
-
xml
下面是效果图
上下滑动的动画
没什么好说的,design包下的协调式布局(CoordinatorLayout),可以很好地实现这种效果,监听AppBarLayout的状态,打开的时候,隐藏标题,关闭的时候.显示标题,同时监听高度变化做透明度动画效果
-
XML布局
-
AppBarStateChangeListener
public abstract class AppBarStateChangeListener implements AppBarLayout.OnOffsetChangedListener {
private State mCurrentState = State.IDLE;
@Override
public final void onOffsetChanged(AppBarLayout appBarLayout, int i) {
if (i == 0) {
if (mCurrentState != State.EXPANDED) {
onStateChanged(appBarLayout, State.EXPANDED);
}
mCurrentState = State.EXPANDED;
} else if (Math.abs(i) >= appBarLayout.getTotalScrollRange()) {
if (mCurrentState != State.COLLAPSED) {
onStateChanged(appBarLayout, State.COLLAPSED);
}
mCurrentState = State.COLLAPSED;
} else {
if (mCurrentState != State.IDLE) {
onStateChanged(appBarLayout, State.IDLE);
}
mCurrentState = State.IDLE;
}
onStateChanged(i);
Logger.d("滑动的的高度" + i);
}
public abstract void onStateChanged(AppBarLayout appBarLayout, State state);
public void onStateChanged(int i) {
}
public enum State {
EXPANDED, // 展开状态
COLLAPSED, // 折叠状态
IDLE // 准备状态
}
}
-
activity
private void initToolbar() {
mAppBarLayout.addOnOffsetChangedListener(new AppBarStateChangeListener() {
@Override
public void onStateChanged(AppBarLayout appBarLayout, State state) {
if (state == State.EXPANDED) {
// 展开状态
mTvTitle.setText("");
mRlHeader.setVisibility(View.VISIBLE);
} else if (state == State.COLLAPSED) {
// 折叠状态
mTvTitle.setText("芭比馒头");
mRlHeader.setVisibility(View.GONE);
} else {
mTvTitle.setText("");
mRlHeader.setVisibility(View.VISIBLE);
}
}
@Override
public void onStateChanged(int i) {
float height = mRlHeader.getHeight();
float alpha = i / height;
Logger.d("透明度" + (1 - Math.abs(alpha)));
mRlHeader.setAlpha(1 - Math.abs(alpha));
}
});
}
上面要注意的是,动态设置mRlHeader的显示隐藏,因为透明度有时候不为0的时候,还是会显示出来的
添加减少的动画
使用的是属性动画,单这里有一个坑,后面会讲到,尝试过用补间动画,但是由于补间动画的特性,本身的位置不变,达不到预期效果,有兴趣的可以尝试一下
-
动画的代码
public void animOpen(final ImageView imageView) {
AnimatorSet animatorSet = new AnimatorSet();
ObjectAnimator translationAnim = ObjectAnimator.ofFloat(imageView, "translationX", addLeft - reduceLeft, 0);
ObjectAnimator rotationAnim = ObjectAnimator.ofFloat(imageView, "rotation", 0, 180);
animatorSet.play(translationAnim).with(rotationAnim);
animatorSet.setDuration(TIME).start();
}
public void animClose(final ImageView imageView) {
AnimatorSet animatorSet = new AnimatorSet();
ObjectAnimator translationAnim = ObjectAnimator.ofFloat(imageView, "translationX", 0, addLeft - reduceLeft);
ObjectAnimator rotationAnim = ObjectAnimator.ofFloat(imageView, "rotation", 0, 180);
animatorSet.play(translationAnim).with(rotationAnim);
animatorSet.setDuration(TIME).start();
}
-
动画打开还是关闭
// 减少
final ImageView iv_goods_reduce = holder.getView(R.id.iv_goods_reduce);
iv_goods_reduce.getViewTreeObserver().addOnGlobalLayoutListener(new ViewTreeObserver.OnGlobalLayoutListener() {
@Override
public void onGlobalLayout() {
// 获取减少图标的位置
reduceLeft = iv_goods_reduce.getLeft();
iv_goods_reduce.getViewTreeObserver().removeOnGlobalLayoutListener(this);
}
});
iv_goods_reduce.setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View v) {
int position = holder.getLayoutPosition();
ShopGoodsBean shopGoodsBean = mGoodsList.get(position);
int count = shopGoodsBean.getCount();
count--;
// 防止过快点击出现多个关闭动画
if (count == 0) {
animClose(iv_goods_reduce);
tv_goods_count.setText("");
// 考虑到用户点击过快
allCount--;
} else if (count < 0) {
// 防止过快点击出现商品数为负数
count = 0;
} else {
allCount--;
tv_goods_count.setText(String.valueOf(count));
}
// 商品的数量是否显示
if (allCount <= 0) {
allCount = 0;
mTvShoppingCartCount.setVisibility(View.GONE);
} else {
mTvShoppingCartCount.setText(String.valueOf(allCount));
mTvShoppingCartCount.setVisibility(View.VISIBLE);
}
shopGoodsBean.setCount(count);
}
});
// 增加
final ImageView iv_goods_add = holder.getView(R.id.iv_goods_add);
iv_goods_add.getViewTreeObserver().addOnGlobalLayoutListener(new ViewTreeObserver.OnGlobalLayoutListener() {
@Override
public void onGlobalLayout() {
// 获取增加图标的位置
addLeft = iv_goods_add.getLeft();
iv_goods_add.getViewTreeObserver().removeOnGlobalLayoutListener(this);
}
});
iv_goods_add.setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View v) {
int position = holder.getLayoutPosition();
ShopGoodsBean shopGoodsBean = mGoodsList.get(position);
int count = shopGoodsBean.getCount();
count++;
allCount++;
if (allCount > 0) {
mTvShoppingCartCount.setVisibility(View.VISIBLE);
}
mTvShoppingCartCount.setText(String.valueOf(allCount));![1.gif](https://upload-images.jianshu.io/upload_images/5286943-8616ee25e4dd2edc.gif?imageMogr2/auto-orient/strip)
if (count == 1) {
iv_goods_reduce.setVisibility(View.VISIBLE);
animOpen(iv_goods_reduce);
}
addGoods2CartAnim(iv_goods_add);
tv_goods_count.setText(String.valueOf(count));
shopGoodsBean.setCount(count);
}
});
通过getViewTreeObserver获取图标的位置,计算距离,做属性动画,同时通过数据层记录,要注意的是,如果手指点击点击过快的时候,会使减少的变为负数,所以我们要做判断,如果为0不减少
你可以看到,减少的图标不见了,而且点击也没效果,很简单,主要是RecycleView的item复用机制,属性动画改变控件位置,所以做完动画位置还原就好了
public void animClose(final ImageView imageView) {
AnimatorSet animatorSet = new AnimatorSet();
ObjectAnimator translationAnim = ObjectAnimator.ofFloat(imageView, "translationX", 0, addLeft - reduceLeft);
ObjectAnimator rotationAnim = ObjectAnimator.ofFloat(imageView, "rotation", 0, 180);
animatorSet.play(translationAnim).with(rotationAnim);
animatorSet.setDuration(TIME).start();
animatorSet.addListener(new AnimatorListenerAdapter() {
@Override
public void onAnimationEnd(Animator animation) {
// TODO: 2018/5/19 因为属性动画会改变位置,所以当结束的时候,要回退的到原来的位置,同时用补间动画的位移不好控制
ObjectAnimator oa = ObjectAnimator.ofFloat(imageView, "translationX", addLeft - reduceLeft, 0);
oa.setDuration(0);
oa.start();
imageView.setVisibility(View.GONE);
}
});
}
4 贝塞尔曲线动画
一个二阶贝塞尔动画,没有涉及到动态点,比较简单,在你点击的时候,在最外层布局添加一个ImageView,做值动画,动画结束的时候,移除该控件
/**
* 贝塞尔曲线动画
*
* @param goodsImageView
*/
public void addGoods2CartAnim(ImageView goodsImageView) {
final ImageView goods = new ImageView(ShoppingGoodsActivity.this);
goods.setImageResource(R.mipmap.icon_goods_add);
int size = Util.dp2px(ShoppingGoodsActivity.this, 24);
ViewGroup.LayoutParams lp = new ViewGroup.LayoutParams(size, size);
goods.setLayoutParams(lp);
mCoordinatorLayout.addView(goods);
// 控制点的位置
int[] recyclerLocation = new int[2];
mCoordinatorLayout.getLocationInWindow(recyclerLocation);
// 加入点的位置起始点
int[] startLocation = new int[2];
goodsImageView.getLocationInWindow(startLocation);
// 购物车的位置终点
int[] endLocation = new int[2];
mIvShoppingCart.getLocationInWindow(endLocation);
// TODO: 2018/5/21 0021 考虑到状态栏的问题,不然会往下偏移状态栏的高度
int startX = startLocation[0] - recyclerLocation[0];
int startY = startLocation[1] - recyclerLocation[1];
// TODO: 2018/5/21 0021 和上面一样
int endX = endLocation[0] - recyclerLocation[0];
int endY = endLocation[1] - recyclerLocation[1];
// 开始绘制贝塞尔曲线
Path path = new Path();
// 移动到起始点位置(即贝塞尔曲线的起点)
path.moveTo(startX, startY);
// 使用二阶贝塞尔曲线:注意第一个起始坐标越大,贝塞尔曲线的横向距离就会越大,一般按照下面的式子取即可
path.quadTo((startX + endX) / 2, startY, endX, endY);
// mPathMeasure用来计算贝塞尔曲线的曲线长度和贝塞尔曲线中间插值的坐标,如果是true,path会形成一个闭环
final PathMeasure pathMeasure = new PathMeasure(path, false);
// 属性动画实现(从0到贝塞尔曲线的长度之间进行插值计算,获取中间过程的距离值)
ValueAnimator valueAnimator = ValueAnimator.ofFloat(0, pathMeasure.getLength());
// 计算距离
int tempX = Math.abs(startX - endX);
int tempY = Math.abs(startY - endY);
// 根据距离计算时间
int time = (int) (0.3 * Math.sqrt((tempX * tempX) + tempY * tempY));
valueAnimator.setDuration(time);
valueAnimator.start();
valueAnimator.setInterpolator(new AccelerateInterpolator());
valueAnimator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
@Override
public void onAnimationUpdate(ValueAnimator animation) {
// 当插值计算进行时,获取中间的每个值,
// 这里这个值是中间过程中的曲线长度(下面根据这个值来得出中间点的坐标值)
float value = (Float) animation.getAnimatedValue();
// 获取当前点坐标封装到mCurrentPosition
// boolean getPosTan(float distance, float[] pos, float[] tan) :
// 传入一个距离distance(0<=distance<=getLength()),然后会计算当前距离的坐标点和切线,pos会自动填充上坐标,这个方法很重要。
// mCurrentPosition此时就是中间距离点的坐标值
pathMeasure.getPosTan(value, mCurrentPosition, null);
// 移动的商品图片(动画图片)的坐标设置为该中间点的坐标
goods.setTranslationX(mCurrentPosition[0]);
goods.setTranslationY(mCurrentPosition[1]);
}
});
valueAnimator.addListener(new AnimatorListenerAdapter() {
@Override
public void onAnimationEnd(Animator animation) {
// 移除图片
mCoordinatorLayout.removeView(goods);
// 购物车数量增加
mTvShoppingCartCount.setText(String.valueOf(allCount));
}
});
}
5 底部购物车弹窗动画
底部的一个动画效果,大致原理就是,用一个FrameLayout,底部保持不动,上面的ViewGroup做位移动画,同时判断商品个数,动态控制RecycleView的高度
-
动态设置RecycleView的高度
private void initAdapter() {
// 如果商品个数大于指定数时,高度写死,其他wrap_content
if (list.size() >= 4) {
ViewGroup.LayoutParams lp = mRvCartGoods.getLayoutParams();
lp.width = ViewGroup.LayoutParams.MATCH_PARENT;
lp.height = DensityUtil.dp2px(200);
mRvCartGoods.setLayoutParams(lp);
}
mAdapter = new BaseAdapter(list, R.layout.item_cart_goods, this);
mRvCartGoods.setLayoutManager(new LinearLayoutManager(mActivity));
mRvCartGoods.setAdapter(mAdapter);
}
网上找过很多设置最大值的,都没有效果,后面直接改用代码动态设置
-
动画效果
private void initScreen() {
WindowManager wm = (WindowManager) mActivity.getSystemService(Context.WINDOW_SERVICE);
DisplayMetrics dm = new DisplayMetrics();
wm.getDefaultDisplay().getMetrics(dm);
// 获取屏幕的高度
mHeightPixels = dm.heightPixels;
}
public void openAnim() {
ObjectAnimator objectAnimator = ObjectAnimator.ofFloat(mLlShoppingCart, "translationY", mHeightPixels, 0);
objectAnimator.setDuration(TIME);
objectAnimator.start();
}
public void closeAnim() {
ObjectAnimator objectAnimator = ObjectAnimator.ofFloat(mLlShoppingCart, "translationY", 0, mHeightPixels);
objectAnimator.setDuration(TIME);
objectAnimator.start();
objectAnimator.addListener(new AnimatorListenerAdapter() {
@Override
public void onAnimationEnd(Animator animation) {
super.onAnimationEnd(animation);
dismiss();
}
});
}
可以看到,在关闭的时候做了一个动画监听,直接调用dismiss()方法,会直接销毁掉,我们要等到动画执行完毕之后在销毁掉
购物车缓存
storeId为文件名,同时内部用一个map集合储存数据,key为goodsID,value为count,allCount为商铺总数
/**
* 添加商品缓存
*
* @param storeId 商品的id
*/
public ShoppingCartHistoryManager add(String storeId, @NonNull StoreGoodsBean storeGoodsBean) {
File file = new File(PATH);
if (!file.exists()) {
file.mkdirs();
}
FileOutputStream fileOutputStream = null;
ObjectOutputStream objectOutputStream = null;
try {
fileOutputStream = new FileOutputStream(file.getAbsolutePath() + File.separator + storeId + FILE_FORMAT);
objectOutputStream = new ObjectOutputStream(fileOutputStream);
objectOutputStream.writeObject(storeGoodsBean);
objectOutputStream.flush();
objectOutputStream.close();
} catch (Exception e) {
e.printStackTrace();
}
return this;
}
/**
* 得到商品缓存
*
* @param storeId 商铺的id
*/
public HashMap get(String storeId) {
File file = new File(PATH);
if (!file.exists()) {
return null;
}
FileInputStream FileInputStream = null;
ObjectInputStream objectInputStream = null;
StoreGoodsBean storeGoodsBean = null;
try {
FileInputStream = new FileInputStream(file.getAbsolutePath() + File.separator + storeId + FILE_FORMAT);
objectInputStream = new ObjectInputStream(FileInputStream);
storeGoodsBean = (StoreGoodsBean) objectInputStream.readObject();
objectInputStream.close();
} catch (Exception e) {
e.printStackTrace();
return null;
}
return storeGoodsBean.getHashMap();
}
/**
* 获取商铺选择的总个数
*
* @param storeId 商铺id
* @return
*/
public int getAllGoodsCount(String storeId) {
HashMap hashMap = get(storeId);
int allCount = 0;
if (hashMap != null) {
for (Map.Entry entry : hashMap.entrySet()) {
Integer value = entry.getValue();
if (value != 0) {
allCount += value;
}
}
}
return allCount;
}
/**
* 删除商铺缓存,如果数量为0
*
* @param storeId 商铺的id
*/
public ShoppingCartHistoryManager delete(@NonNull String storeId) {
File file = new File(PATH, storeId + FILE_FORMAT);
if (file.exists()) {
file.delete();
}
return this;
}
/**
* 删除商铺缓存,如果数量为0
*
* @param ShopId 商铺的id
*/
public ShoppingCartHistoryManager delete(@NonNull int ShopId) {
File file = new File(PATH, +ShopId + FILE_FORMAT);
if (file.exists()) {
file.delete();
}
return this;
}
退出页面时,根据商铺的总数是否为0,选择删除数据(如果文件之前存在)还是保存数据
@Override
protected void onDestroy() {
super.onDestroy();
if (allCount != 0) {
HashMap hashMap = new HashMap<>();
StoreGoodsBean storeGoodsBean = new StoreGoodsBean(hashMap);
for (ShopGoodsBean bean : mGoodsList) {
int count = bean.getCount();
String goodsId = bean.getGoodsId();
if (count != 0) {
hashMap.put(goodsId, count);
}
}
ShoppingCartHistoryManager.getInstance().add(SHOP_ID, storeGoodsBean);
} else {
ShoppingCartHistoryManager.getInstance().delete(SHOP_ID);
}
}
进入页面的时候,获取缓存数据(如果存在)
private void initData() {
int id = 0x100;
HashMap hashMap = ShoppingCartHistoryManager.getInstance().get(SHOP_ID);
this.allCount = ShoppingCartHistoryManager.getInstance().getAllGoodsCount(SHOP_ID);
showToast("商品总数" + allCount);
// 根据缓存是否显示
mTvShoppingCartCount.setVisibility(allCount == 0 ? View.GONE : View.VISIBLE);
mTvShoppingCartCount.setText(String.valueOf(allCount));
// TODO: 2018/6/5 0005 模拟请求到的数据
for (int i = 0; i < 10; i++) {
mGoodsList.add(new ShopGoodsBean(0, "小猪包套餐" + i, id++ + ""));
}
if (hashMap != null) {
for (ShopGoodsBean bean : mGoodsList) {
String goodsId = bean.getGoodsId();
if (hashMap.containsKey(goodsId)) {
Integer count = hashMap.get(goodsId);
bean.setCount(count);
}
}
}
}
在最外面的商铺列表通过storeId获取商铺总数
this.allCount =ShoppingCartHistoryManager.getInstance().getAllGoodsCount(SHOP_ID);