自定义控件&属性动画实现Tab主菜单

1,772 阅读5分钟

前言

设计出了一个新的桌面交互效果让实现

jo4w9-u060d.gif

设计刚发给我产品问我多久能做完,心里慌得一批。不慌!先做一下动画拆解

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 要求的效果还原度很高,但是有一个点不甚满意的是

image.png

如图所示,我们的应用结构可能和 pad 有点类似,中间一个 container 是经常被替换的。othder fragment 是常驻的,例如左侧的菜单动画效果和底部唱片旋转效果都是在 othder fragment 常驻,这个时候 container 里面的 fragment 如果是没有常驻在内存里面去(冷启动),页面打开的一瞬间是执行的动画会卡顿的,卡顿一瞬间正常恢复,唱片的旋转效果应该是可以转换成 surfaceview 尝试去避免这种场景产生的瞬间卡顿问题,但是左侧主菜单目前没有太好的优化思路,欢迎各位看官评论留言多多交流。