前言
设计出了一个新的桌面交互效果让实现
设计刚发给我产品问我多久能做完,心里慌得一批。不慌!先做一下动画拆解
1 页面之间的跳转应该好说加页面的转场动画即可
例:
new NavOptions.Builder().setEnterAnim(R.anim.enter_anim)
.setExitAnim(R.anim.exit_anim).build();
2 按钮点击的动效也比较好实现封装一个通用的属性动画即可
public static void buttonScaleAnimator(View view, AnimatorClickListener listener) {
if (view != null && listener != null) {
view.animate().scaleY(0.9f).scaleX(0.9f).setDuration(150).withEndAction(
() -> view.animate().scaleY(1.0f).scaleX(1.0f).setDuration(150)
.withEndAction(
() -> listener.onClick(view)));
}
}
3 后面追加了一个右下角侧易烊千玺的唱片转动效果,最简单的方式通过视图动画就能实现
RotateAnimation a = new RotateAnimation(0.0f, 360.0f, Animation.RELATIVE_TO_SELF, 0.5f,
Animation.RELATIVE_TO_SELF, 0.5f);
a.setDuration(10000);
a.setInterpolator(new LinearInterpolator());
a.setRepeatCount(INFINITE);
这里需要注意一定要设置线性的插值器,不然就不是唱片旋转的匀速效果(默认是加减速插值器)。另外如果想要实现和播放器按下暂停、回复播放唱片旋转角度保持,还需要做下自定义 view 的封装,计算一下暂停时候的角度用来恢复播放状态后唱片旋转的初始角度
主菜单Tab&动效实现
上面的动画效果分析得差不多了,还剩下一个左侧的主菜单的动画。算是拆解出来里面比较复杂的,原本的旧版是网上的一个 tab 开源库,无法满足我们动画的需求,此处我对它进行分解重写。
状态分析
分析左侧主菜单一共有两对四个状态
child view 的选中和反选状态
menu 的抽屉打开和关闭状态
动画分析
child view 的动画:文字渐隐、child 背景平移(抽屉收起再弹出),整个 layout 的宽度改变
背景动画比较简单就是做背景的平移即可
设计思路
SmartMenuLayout
把几个状态理清楚和动画做了拆解后面就好设计实现了,考虑扩展性子的 child view 肯定不是固定的 3 个,多个子 child 同一时刻肯定只有一个是选中其他都是非选中,这种情况就非常适合用观察者,当一个 child 被选中时我们是知道的,这个时候通知其他的 child 去做状态变更,当然不是这个被选中的 view 去做这个事。我们设计一个主的 layout 去包裹所有的 child 以及对 child 进行统一管理,同时这个主的 layout 也充当对外提供接口调用以及被订阅的角色,我们将这个主的 layout 称为 SmartMenuLayout
public class SmartMenuLayout extends LinearLayout implements OnClickListener {
public static final String TAG = SmartMenuLayout.class.getSimpleName();
private final List<MenuObserver> observers = new ArrayList<>();
private ImageView mainMenuBg;
private int mCurrentIndex = 0;
private boolean expandStatus = true;
public SmartMenuLayout(Context context) {
this(context, null);
}
public SmartMenuLayout(Context context,
@Nullable AttributeSet attrs) {
this(context, attrs, 0);
}
public SmartMenuLayout(Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
initView(context);
}
private void initView(Context context) {
LinearLayout rootView = (LinearLayout) inflate(context, R.layout.smart_menu_layout, this);
LinearLayout itemsView = rootView.findViewById(R.id.menu_item_layout);
mainMenuBg = rootView.findViewById(R.id.main_menu_bg);
int textColorNormal = getResources().getColor(R.color.color_A8A8A8);
int textColorSelected = getResources().getColor(R.color.color_D8D8D8);
int defaultIndex = 0;
for (int i = 0; i < itemsView.getChildCount(); i++) {
MenuItemView itemView = (MenuItemView) itemsView.getChildAt(i);
LayoutParams layoutParams = new LayoutParams(LayoutParams.MATCH_PARENT,
LayoutParams.WRAP_CONTENT);
layoutParams.topMargin = i == 0 ? 180 : 20;
layoutParams.bottomMargin = 20;
itemView.setLayoutParams(layoutParams);
itemView.setTag(i);
itemView.setTextColor(textColorNormal, textColorSelected);
itemView.getImageBg().setOnClickListener(this);
itemView.getImageView().setOnClickListener(this);
itemView.getTextView().setOnClickListener(this);
itemView.setSelected(defaultIndex == i);
register(itemView);
}
}
private void register(MenuObserver observer) {
if (observers.contains(observer)) {
return;
}
observers.add(observer);
}
private void notifyEveryOne(int index) {
for (MenuObserver observer : observers) {
observer.update(index);
}
}
private void notifyItemUpdateStyle(MenuItemModel tabModel) {
for (MenuObserver observer : observers) {
observer.updateItemStyle(tabModel);
}
}
public void refreshMenusStyle(List<MenuItemModel> models) {
for (MenuItemModel m : models) {
notifyItemUpdateStyle(m);
}
}
@Override
public void onClick(View v) {
int index = (int) v.getTag();
notifyEveryOne(index);
if (mOnTabItemClickListener != null) {
mOnTabItemClickListener.itemClick(index, mCurrentIndex, mCurrentIndex == index);
}
mCurrentIndex = index;
}
private OnTabItemClickListener mOnTabItemClickListener;
public void setOnTabItemClickListener(OnTabItemClickListener onTabItemClickListener) {
mOnTabItemClickListener = onTabItemClickListener;
}
public void animation(boolean isExpand) {
if (expandStatus == isExpand) {
return;
}
for (MenuObserver observer : observers) {
observer.animation(isExpand);
}
if (isExpand) {
mainMenuBg.animate().translationX(0.0f).setDuration(700).setStartDelay(250);
} else {
mainMenuBg.animate().translationX(-mainMenuBg.getWidth()).setDuration(700).setStartDelay(50);
}
expandStatus = isExpand;
}
}
SmartMenuLayout 除了充当对外提供接口、被订阅、通知和管理 children 外还控制上面我们看到的整个背景的抽屉打开和关闭效果。
Child
每个 child 具体负责什么呢?主要包含订阅(观察)来自 SmartMenuLayout 的事件,SmartMenuLayout 会告诉观察到事件这一刻该 child 应该是正选还是反选,抽屉打开还是关闭状态,其余的就是完成正反选的 UI 以及抽屉打开关闭的动效了。
public interface MenuObserver {
void update(int index);
void updateItemStyle(MenuItemModel model);
void animation(boolean isExpand);
}
public class MenuItemView extends LinearLayout implements MenuObserver {
private TextView mTextView;
private ImageView mImageView;
private ImageView mImageBg;
private int mColorNormal;
private int mColorSelected;
private int mDrawableNormal;
private int mDrawableSelected;
public MenuItemView(Context context) {
this(context, null);
}
public MenuItemView(Context context,
@Nullable AttributeSet attrs) {
this(context, attrs, 0);
}
public MenuItemView(Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
initView(context, attrs);
}
private void initView(Context context, AttributeSet attrs) {
inflate(context, R.layout.smart_menu_item, this);
mTextView = findViewById(R.id.menu_item_text);
mImageView = findViewById(R.id.menu_item_img);
mImageBg = findViewById(R.id.menu_item_bg);
TypedArray typedArray = context.obtainStyledAttributes(attrs, R.styleable.MenuItemView);
Drawable drawable = typedArray.getDrawable(R.styleable.MenuItemView_menu_item_image_src);
if (drawable != null) {
mImageView.setImageDrawable(drawable);
}
CharSequence text = typedArray.getText(R.styleable.MenuItemView_menu_item_text_string);
if (!TextUtils.isEmpty(text)) {
mTextView.setText(text);
}
typedArray.recycle();
}
public void setTextColor(@ColorInt int colorNormal, @ColorInt int colorSelected) {
mColorNormal = colorNormal;
mColorSelected = colorSelected;
mTextView.setTextColor(isSelected() ? colorSelected : colorNormal);
}
@Override
public void update(int index) {
setSelected(index == (int) getTag());
}
@SuppressLint("UseCompatLoadingForDrawables")
@Override
public void updateItemStyle(MenuItemModel model) {
if (model.getIndex() == (int) getTag()) {
if (!TextUtils.isEmpty(model.getText())) {
mTextView.setText(model.getText());
}
if (model.getImageNormal() != 0 && model.getImageSelected() != 0) {
mDrawableNormal = model.getImageNormal();
mDrawableSelected = model.getImageSelected();
mImageView.setImageDrawable(isSelected() ? getResources().getDrawable(mDrawableSelected)
: getResources().getDrawable(mDrawableNormal));
}
}
}
@Override
public void animation(boolean isExpand) {
int width = mImageBg.getWidth();
if (isExpand) {
mTextView.animate().alpha(0.0f).alpha(1.0f).setDuration(300);
mImageBg.animate().translationX(-width).setDuration(300).withEndAction(
() -> mImageBg.animate().setStartDelay(150).setDuration(300).translationX(0.0f));
} else {
mTextView.animate().alpha(1.0f).alpha(0.0f).setDuration(300);
mImageBg.animate().translationX(0.0f).translationX(-width)
.setDuration(300).withEndAction(() -> {
mImageBg.animate().setStartDelay(150).translationX(-(width >> 1))
.setDuration(300);
});
}
}
@SuppressLint("UseCompatLoadingForDrawables")
@Override
public void setSelected(boolean selected) {
super.setSelected(selected);
mTextView.setTextColor(selected ? mColorSelected : mColorNormal);
if (mDrawableNormal != 0 && mDrawableSelected != 0) {
mImageView.setImageDrawable(isSelected() ? getResources().getDrawable(mDrawableSelected)
: getResources().getDrawable(mDrawableNormal));
}
mImageBg.setVisibility(selected ? VISIBLE : INVISIBLE);
}
@Override
public void setTag(Object tag) {
super.setTag(tag);
mImageBg.setTag(tag);
mTextView.setTag(tag);
mImageView.setTag(tag);
}
public ImageView getImageBg() {
return mImageBg;
}
public TextView getTextView() {
return mTextView;
}
public ImageView getImageView() {
return mImageView;
}
}
事件点击
封装一下点击事件,把上一次点击和是否重复点击抛出给外面,因为有很多应用存在自己特殊的埋点上报需求。
public interface OnTabItemClickListener {
/**
* @param index 选中下标
* @param lastIndex 上次选中下标
* @param isRepeat 较上次是不是重复选中
*/
void itemClick(int index, int lastIndex, boolean isRepeat);
}
主要介绍了设计思路,具体的动画实现附上文源码中。这样仅仅通过 2 、3个类较为简洁的完成了我们 tab 功能+动画和内聚埋点事件的需求,如果存在 AB测想要进行样式的服务下发动态替换,TAB 个数的动态增减在 SmartMenuLayout 去添加接口都是比较好扩展的。
效果预览
其他
整个动画实现后和 UI 要求的效果还原度很高,但是有一个点不甚满意的是
如图所示,我们的应用结构可能和 pad 有点类似,中间一个 container 是经常被替换的。othder fragment 是常驻的,例如左侧的菜单动画效果和底部唱片旋转效果都是在 othder fragment 常驻,这个时候 container 里面的 fragment 如果是没有常驻在内存里面去(冷启动),页面打开的一瞬间是执行的动画会卡顿的,卡顿一瞬间正常恢复,唱片的旋转效果应该是可以转换成 surfaceview 尝试去避免这种场景产生的瞬间卡顿问题,但是左侧主菜单目前没有太好的优化思路,欢迎各位看官评论留言多多交流。