InstaMaterial 概念设计(第四部分) - 新闻item的上下文菜单

英文原文: http://frogermcs.github.io/InstaMaterial-concept-part-4-feed-context-menu/

译文原文:http://jcodecraeer.com/a/anzhuokaifa/androidkaifa/2015/0412/2708.html 

转载译文务必注明出处。

这篇文章是实现Material 风格的INSTAGRAM 系列文章第第四部分。今天,我们将为新闻的item创建一个从“motre”按钮打开的上下文菜单。大约是视频18 到 20 秒之间的内容。

下面是今天所要达到的效果(分别是Lollipop以及Lollipop之前的效果):

视频暂略

先热身

在我们开始菜单的实现之前,我们先重构以下代码。

Riyaz在评论中指出MainActivityCommentsActivity中的Toolbar是完全一样的,因此可以单独新建一个布局,使用<include />标签来导入以达到重用的效果。

重构之后大概长这样。这个新建的布局文件在res/layout/view_feed_toolbar.xml中:

view_feed_toolbar.xml

<?xml version="1.0" encoding="utf-8"?>
<android.support.v7.widget.Toolbar xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/tools"
    android:id="@+id/toolbar"
    android:layout_width="match_parent"
    android:layout_height="?attr/actionBarSize"
    android:background="?attr/colorPrimary"
    android:elevation="@dimen/default_elevation"
    app:theme="@style/ThemeOverlay.AppCompat.Dark.ActionBar">
    <ImageView
        android:id="@+id/ivLogo"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:layout_gravity="center"
        android:scaleType="center"
        android:src="@drawable/img_toolbar_logo" />
</android.support.v7.widget.Toolbar>

现在我们只需在activity_main.xmlactivity_comments.xml中如下使用它就可以了:

activity_main_include.xml 

<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools"
    android:id="@+id/root"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context=".MainActivity">
 
    <!--...-->
    <include
        android:id="@+id/toolbar"
        layout="@layout/view_feed_toolbar" />
 
    <!--...-->
 
</RelativeLayout>

值得一提的是我们可以在<include />标签中重写一些属性(重写将会覆盖被包含文件中根视图的相关属性),就如上面已经重写了的android:id属性。(顺便说下这里Toolbar需要放在RelativeLayout中,ps:真啰嗦啊,说了几次了。)

这里是 提交 的重构代码。

上下文菜单

准备工作

在开始代码之前我们先做一些准备工作,下面是期望的效果截图:

Context menu screenshot

首先,在item_feed.xml中添加一个按钮,并处理其onClick()事件。这不难,只要将以前的一些代码拷贝过来,再添加一个三个圆点的图片(我用的是从官方Material Design图标包中弄来的 这个图标 )。这里是 提交 的这些更改的代码。

上下文菜单(Context menu)的布局

让我们从view的布局开始吧,我们再一次使用<merge /> 标签,然后在xml和java代码中构建

res/layout/view_context_menu.xml:

<?xml version="1.0" encoding="utf-8"?>
<merge xmlns:android="http://schemas.android.com/apk/res/android">
 
    <Button
        android:id="@+id/btnReport"
        style="@style/ContextMenuButton"
        android:text="REPORT"
        android:textColor="@color/btn_context_menu_text_red" />
     
    <Button
        android:id="@+id/btnSharePhoto"
        style="@style/ContextMenuButton"
        android:text="SHARE PHOTO" />
     
    <Button
        android:id="@+id/btnCopyShareUrl"
        style="@style/ContextMenuButton"
        android:text="COPY SHARE URL" />
     
    <View
        android:layout_width="match_parent"
        android:layout_height="1dp"
        android:background="#eeeeee" />
     
    <Button
        android:id="@+id/btnCancel"
        style="@style/ContextMenuButton"
        android:text="CANCEL" />
 
</merge>

其实就是四个共用如下样式的按钮:

<?xml version="1.0" encoding="utf-8"?>
<!-- styles.xml-->
<resources>
     
    <!--...-->
     
    <style name="ContextMenuButton">
        <item name="android:layout_width">match_parent</item>
        <item name="android:layout_height">wrap_content</item>
        <item name="android:background">@drawable/btn_context_menu</item>
        <item name="android:gravity">left|center_vertical</item>
        <item name="android:paddingLeft">20dp</item>
        <item name="android:paddingRight">20dp</item>
        <item name="android:textColor">?attr/colorPrimary</item>
        <item name="android:textSize">14sp</item>
    </style>
 
</resources>

很简单吧,java代码也同样简单:

public class FeedContextMenu extends LinearLayout {
    private static final int CONTEXT_MENU_WIDTH = Utils.dpToPx(240);
     
    private int feedItem = -1;
     
    private OnFeedContextMenuItemClickListener onItemClickListener;
     
    public FeedContextMenu(Context context) {
        super(context);
        init();
    }
     
    private void init() {
        LayoutInflater.from(getContext()).inflate(R.layout.view_context_menu, this, true);
        setBackgroundResource(R.drawable.bg_container_shadow);
        setOrientation(VERTICAL);
        setLayoutParams(new LayoutParams(CONTEXT_MENU_WIDTH, ViewGroup.LayoutParams.WRAP_CONTENT));
    }
     
    public void bindToItem(int feedItem) {
        this.feedItem = feedItem;
    }
     
    @Override
    protected void onAttachedToWindow() {
        super.onAttachedToWindow();
        ButterKnife.inject(this);
    }
     
    public void dismiss() {
        ((ViewGroup) getParent()).removeView(FeedContextMenu.this);
    }
     
    @OnClick(R.id.btnReport)
    public void onReportClick() {
        if (onItemClickListener != null) {
            onItemClickListener.onReportClick(feedItem);
        }
    }
     
    @OnClick(R.id.btnSharePhoto)
    public void onSharePhotoClick() {
        if (onItemClickListener != null) {
            onItemClickListener.onSharePhotoClick(feedItem);
        }
    }
     
    @OnClick(R.id.btnCopyShareUrl)
    public void onCopyShareUrlClick() {
        if (onItemClickListener != null) {
            onItemClickListener.onCopyShareUrlClick(feedItem);
        }
    }
     
    @OnClick(R.id.btnCancel)
    public void onCancelClick() {
        if (onItemClickListener != null) {
            onItemClickListener.onCancelClick(feedItem);
        }
    }
     
    public void setOnFeedMenuItemClickListener(OnFeedContextMenuItemClickListener onItemClickListener) {
        this.onItemClickListener = onItemClickListener;
    }
     
    public interface OnFeedContextMenuItemClickListener {
        public void onReportClick(int feedItem);
         
        public void onSharePhotoClick(int feedItem);
         
        public void onCopyShareUrlClick(int feedItem);
         
        public void onCancelClick(int feedItem);
    }
}

有没有注意到从这里开始作者使用了注解-译者注。

第20行我们使用了模拟的方式来绑定数据模型(直接和一个int类型关联),在实际项目中肯定会更复杂。第30行的dismiss()方法帮助我们将菜单从父视图中移除。其余代码都很简单。

其实这里作者又用到了观察者模式, OnFeedContextMenuItemClickListener在这里就是观察者,将里面的各种点击事件,暴露给外部调用-译者注。

.9图片背景

你应该注意到了我们的菜单是有阴影效果的。在Lollipop中我们可以直接使用原生的elevation值来达到这种效果,但是这只适合最新的安卓设备,即使你使用ViewCompat.setElevation();也无济于事。(如果你分析ViewCompat中的实现,你会发现只是判断了下是否是在Lollipop平台下,否则什么也不做)。

这就是为什么我们要使用.9图片作为背景。为了照顾没有接触过.9图片的同学,简要的说明下:.9图片是一个按一定规则定义了拉伸区域与内容区域的图片,它帮助我们创建一个可以缩放而不失真的图片。

下面是我们的背景图片:

bg_container_shadow.9.png

左边和上边的黑线定义可以被拉伸的区域(其实就是为了防止四个圆角被拉伸),右边和下边的黑线定义了被融区域(从覆盖的范围可以看出,阴影部分未被包括,那么它会自动的给view加上一个和阴影部分相同的padding)

这里你可以找到关于NinePatch的更多解释。

Selector

菜单布局中,最后一件事是点击的按下效果(称作selector)。我们还是直接拷贝之前的现成代码(包括Lollipop和Lollipop之前两个版本):

res/drawable/btn_context_menu.xml/:

<?xml version="1.0" encoding="utf-8"?>
<!--drawable/btn_context_menu.xml-->
<selector xmlns:android="http://schemas.android.com/apk/res/android">
    <item android:drawable="@color/btn_context_menu_normal" android:state_focused="false" android:state_pressed="false" />
    <item android:drawable="@color/btn_context_menu_pressed" android:state_pressed="true" />
    <item android:drawable="@color/btn_context_menu_pressed" android:state_focused="true" />
</selector>

res/drawable-v21/btn_context_menu.xml/:

<?xml version="1.0" encoding="utf-8"?>
<!--drawable-v21/btn_context_menu.xml-->
<ripple xmlns:android="http://schemas.android.com/apk/res/android"
    android:color="@color/btn_context_menu_pressed">
    <item>
        <shape android:shape="rectangle">
            <solid android:color="@color/btn_context_menu_normal" />
        </shape>
    </item>
</ripple>

Context menu manager

context-menu.gif

有了上下文菜单的整个布局,现在可以开始逻辑部分了。为了方便使用,我们用FeedContextMenuManager来管理这些逻辑。下面是FeedContextMenuManager需要满足的需求:

1.一次只能有一个菜单显示在屏幕上

2.点击按钮“more”之后显示菜单,再次点击则菜单消失。

3.菜单需要显示在被点击按钮的正上方。

4.在滚动新闻列表的时候菜单消失。

实现第一点最简单的方式是FeedContextMenuManager作为单例模式来实现。

manager需要处理菜单视图,其中最重要的就是在恰当的时候将其移除。我们使用OnAttachStateChangeListener以及它的onViewDetachedFromWindow(View v)方法来达到此目的,利用它们,就可以实现在菜单dismiss或者activity destroyed的时候菜单视图被移除。

public class FeedContextMenuManager implements View.OnAttachStateChangeListener {
 
    private static FeedContextMenuManager instance;
     
    private FeedContextMenu contextMenuView;
     
    public static FeedContextMenuManager getInstance() {
        if (instance == null) {
            instance = new FeedContextMenuManager();
        }
        return instance;
    }
     
     
    @Override
    public void onViewAttachedToWindow(View v) {
     
    }
     
    @Override
    public void onViewDetachedFromWindow(View v) {
        contextMenuView = null;
    }
 
}

下面的方法实现显示和隐藏view

public void toggleContextMenuFromView(View openingView, int feedItem, FeedContextMenu.OnFeedContextMenuItemClickListener listener) {
    if (contextMenuView == null) {
        showContextMenuFromView(openingView, feedItem, listener);
    } else {
        hideContextMenu();
    }
}

We will invoke it directly from “more button” onClick callback.

Great, now let’s think about showing our menu. First of all we have to take care about “isAnimating” state (in both hiding and showing cases). It’s important to block our manager while animation is executing to prevent doing it twice or more in the same time (and to avoid bugs connected with this).

The rest is in code (some explanations below):

我们直接在“more”按钮的onClick回调中触发这个方法。

现在我们来想想显示和隐藏的具体实现。首先需要考虑的是“isAnimating” 状态(在显示和隐藏两种情况下都需考虑)。当动画执行的过程中,很有必要阻断manager以防止执行两次(同时也避免因此而导致的不可预测的bug)。

下面是剩余的代码(以及代码下面的注释):

private void showContextMenuFromView(final View openingView, int feedItem, FeedContextMenu.OnFeedContextMenuItemClickListener listener) {
    if (!isContextMenuShowing) {
        isContextMenuShowing = true;
        contextMenuView = new FeedContextMenu(openingView.getContext());
        contextMenuView.bindToItem(feedItem);
        contextMenuView.addOnAttachStateChangeListener(this);
        contextMenuView.setOnFeedMenuItemClickListener(listener);
         
        ((ViewGroup) openingView.getRootView().findViewById(android.R.id.content)).addView(contextMenuView);
         
        contextMenuView.getViewTreeObserver().addOnPreDrawListener(new ViewTreeObserver.OnPreDrawListener() {
            @Override
            public boolean onPreDraw() {
                contextMenuView.getViewTreeObserver().removeOnPreDrawListener(this);
                setupContextMenuInitialPosition(openingView);
                performShowAnimation();
                return false;
            }
        });
    }
}
 
private void setupContextMenuInitialPosition(View openingView) {
    final int\[\] openingViewLocation = new int\[2\];
    openingView.getLocationOnScreen(openingViewLocation);
    int additionalBottomMargin = Utils.dpToPx(16);
    contextMenuView.setTranslationX(openingViewLocation\[0\] - contextMenuView.getWidth() / 3);
    contextMenuView.setTranslationY(openingViewLocation\[1\] - contextMenuView.getHeight() - additionalBottomMargin);
}
 
private void performShowAnimation() {
    contextMenuView.setPivotX(contextMenuView.getWidth() / 2);
    contextMenuView.setPivotY(contextMenuView.getHeight());
    contextMenuView.setScaleX(0.1f);
    contextMenuView.setScaleY(0.1f);
    contextMenuView.animate()
        .scaleX(1f).scaleY(1f)
        .setDuration(150)
        .setInterpolator(new OvershootInterpolator())
        .setListener(new AnimatorListenerAdapter() {
            @Override
            public void onAnimationEnd(Animator animation) {
                isContextMenuShowing = false;
            }
        });
}

关于上面代码的三点解释:

  • Context menu 初始化- 创建菜单对象,设置一些事件,添加菜单到根view。最后一条我们使用始终指向activity根view的android.R.id.content。在本例中添加view很简单,因为我们知道根view是`RelativeLayout`(`FrameLayout`也同样适用),但是在其他情况下比如根view是LinearLayout,就会复杂一点。

    

  • Menu 的位置- 我们知道menu的初始位置其实是在activity的左上角,我们也知道打开菜单的按钮在屏幕的什么位置,因此这就是个数学问题。这里最重要的是选择一个计算位置的恰当时机。我们需要在onPreDraw()回调中来做这件事情,确保view以及布局完成。否则getWidth()getHeight()返回的是0。

  • Menu 动画- 不过是ViewPropertyAnimator的运用罢了。

隐藏menu更简单,都不需要解释:

public void hideContextMenu() {
    if (!isContextMenuDismissing) {
        isContextMenuDismissing = true;
        performDismissAnimation();
    }
}
 
private void performDismissAnimation() {
    contextMenuView.setPivotX(contextMenuView.getWidth() / 2);
    contextMenuView.setPivotY(contextMenuView.getHeight());
    contextMenuView.animate()
        .scaleX(0.1f).scaleY(0.1f)
        .setDuration(150)
        .setInterpolator(new AccelerateInterpolator())
        .setStartDelay(100)
        .setListener(new AnimatorListenerAdapter() {
            @Override
            public void onAnimationEnd(Animator animation) {
                if (contextMenuView != null) {
                    contextMenuView.dismiss();
                }
                isContextMenuDismissing = false;
            }
        });
}

最后一件事情是在滚动列表的时候隐藏菜单,为此我们继承RecyclerView.OnScrollListener 类:

public class FeedContextMenuManager extends RecyclerView.OnScrollListener implements View.OnAttachStateChangeListener {
 
    //...
     
    public void onScrolled(RecyclerView recyclerView, int dx, int dy) {
        if (contextMenuView != null) {
            hideContextMenu();
            contextMenuView.setTranslationY(contextMenuView.getTranslationY() - dy);
        }
    }
     
    //..
}

你可以看到这里的实现方式有点技巧。在菜单隐藏的同时使用setTranslationY(),这样就达到了滚动时候的这种效果:

context-menu-hiding.gif

你可能觉得隐藏菜单这种效果理所当然,其实不是,为了区分出不使用setTranslationY()会发生什么,你可以将其注释掉看看效果-译者注。

现在我们所要做的就是使用好这个FeedContextMenuManager了,这里是  最后一次提交 的代码。

来自:InstaMaterial概念设计