几个月前(这篇文章的日期是2014 年11月10日),google发布了app和web应用的Material Design设计准则之后,设计师Emmanuel Pacamalan在youtube上发布了一则概念视频,演示了Instagram如果做成Material风格会是什么样子:
视频地址 http://v.youku.com/v_show/id_XODg2NDQ1NDQ4.html
这仅仅是停留在图像上的设计,是美好的愿景,估计很多人都会问,能否使用相对简单的办法将它实现出来呢?答案是:yes,不仅仅能实现,而且无须要求在Lillipop版本,实际上几年前4.0发布之后我们就可以实现这些效果了。ps 读到这里我们应该反思这几年开发者是不是都吃屎去了。
鉴于这个原因,我决定开始撰写一个新的课题-如何将INSTAGRAM with Material Design 视频中的效果转变成现实。当然,我们并不是真的要做一个Instagram应用,只是将界面做出来而已,并且尽量减少一些不必要的细节。
开始
本文将要实现的是视频中前7秒钟的效果。我觉得对于第一次尝试来说已经足够了。我想要提醒诸位的是,里面的实现方法不仅仅是能实现,也是我个人最喜欢的实现方式。还有,我不是一个美工,因此项目中的所有图片是直接从网上公开的渠道获取的。(主要是从resources page)。
好了,下面是最终效果的两组截图和视频(很短的视频,就是那7秒钟的效果,可以在上面的视频中看到,这里因为没法直接引用youtube的视频就略了)(分别从Android 4 和5上获得的):
准备
在我们的项目中,将使用一些热门的android开发工具和库。并不是所有这些东西本篇文章都会用到,我只是将它们准备好以备不时之需。
初始化项目
首先我们需要创建一个新的android项目。我使用的是Android Studio和gradle的build方式。最低版本的sdk是15(即Android 4.0.4)。然后我们将添加一些依赖。没什么好讲的,下面是build.gradle
以及app/build.gradle
文件的代码:
build.gradle
buildscript { repositories { jcenter() } dependencies { classpath ‘com.android.tools.build:gradle:0.14.0‘ classpath ‘com.jakewharton.hugo:hugo-plugin:1.1.+‘ } } allprojects { repositories { jcenter() } }
app/build.gradle
apply plugin: ‘com.android.application‘ apply plugin: ‘hugo‘ android { compileSdkVersion 21 buildToolsVersion "21.1" defaultConfig { applicationId "io.github.froger.instamaterial" minSdkVersion 15 targetSdkVersion 21 versionCode 1 versionName "1.0" } } dependencies { compile fileTree(dir: ‘libs‘, include: [‘*.jar‘]) compile "com.android.support:appcompat-v7:21.0.0" compile ‘com.android.support:support-v13:21.+‘ compile ‘com.android.support:support-v4:21.+‘ compile ‘com.android.support:palette-v7:+‘ compile ‘com.android.support:recyclerview-v7:+‘ compile ‘com.android.support:cardview-v7:21.0.+‘ compile ‘com.jakewharton:butterknife:5.1.2‘ compile ‘com.jakewharton.timber:timber:2.5.0‘ compile ‘com.facebook.rebound:rebound:0.3.6‘ }
简而言之,我们有如下工具:
一些兼容包(CardView, RecyclerView, Palette, AppCompat)-我喜欢使用最新的控件。当然你完全可以使用ListView Actionbar甚至View/FrameView来替代,但是为什么要这么折腾?
ButterKnife - view注入工具简化我们的代码。(比方说不再需要写findViewById()
来引用view,以及一些更强大的功能)。
Rebound - 我们目前还没有用到,但是我以后肯定会用它。这个facebook开发的动画库可以让你的动画效果看起来更自然。
Timber 和
Hugo - 对这个项目而言并不是必须,我仅仅是用他们打印log。
图片资源
本项目中将使用到一些Material Design的图标资源。App 图标来自于NSTAGRAM with Material Design 视频,这里complete
bunch of images 是项目的全套资源。
样式
我们从定义app的默认样式开始。同时为Android 4和5定义Material Desing样式的最简单的方式是直接继承Theme.AppCompat.NoActionBar 或者 Theme.AppCompat.Light.NoActionBar主题。为什么是NoActionBar?因为新的sdk中为我们提供了实现Actionbar功能的新模式。本例中我们将使用Toolbar控件,基于这个原因-Toolbar是ActionBar更好更灵活的解决方案。我们不会深入讲解这个问题,但你可以去阅读android开发者博客AppCompat
v21。
根据概念视频中的效果,我们在AppTheme中定义了三个基本颜色(基色调):
styles.xml
<?xml version="1.0" encoding="utf-8"?> <!-- styles.xml--> <resources> <style name="AppTheme" parent="Theme.AppCompat.Light.NoActionBar"> <item name="colorPrimary">@color/style_color_primary</item> <item name="colorPrimaryDark">@color/style_color_primary_dark</item> <item name="colorAccent">@color/style_color_accent</item> </style> </resources>
colors1.xml
<?xml version="1.0" encoding="utf-8"?> <!--colors.xml--> <resources> <color name="style_color_primary">#2d5d82</color> <color name="style_color_primary_dark">#21425d</color> <color name="style_color_accent">#01bcd5</color> </resources>
关于这三个颜色的意义,你可以在这里找到Material Theme Color Palette documentation。
布局
项目目前主要使用了3个主要的布局元素
- Toolbar - 包含导航图标和applogo的顶部bar
- RecyclerView - 用于显示feed
- Floating Action Button - 一个实现了Material Design中action button pattern的ImageButton。
在开始实现布局之前,我们先在res/values/dimens.xml
文件中定义一些默认值:
<?xml version="1.0" encoding="utf-8"?> <!--dimens.xml--> <resources> <dimen name="btn_fab_size">56dp</dimen> <dimen name="btn_fab_margins">16dp</dimen> <dimen name="default_elevation">8dp</dimen> </resources>
这些值的大小是基于Material Design设计准则中的介绍。
现在我们来实现MainActivity
中的layout:
activity_main.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"> <android.support.v7.widget.Toolbar android:id="@+id/toolbar" android:layout_width="match_parent" android:layout_height="?attr/actionBarSize" android:background="?attr/colorPrimary"> <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> <android.support.v7.widget.RecyclerView android:id="@+id/rvFeed" android:layout_width="match_parent" android:layout_height="match_parent" android:layout_below="@id/toolbar" android:scrollbars="none" /> <ImageButton android:id="@+id/btnCreate" android:layout_width="@dimen/btn_fab_size" android:layout_height="@dimen/btn_fab_size" android:layout_alignParentBottom="true" android:layout_alignParentRight="true" android:layout_marginBottom="@dimen/btn_fab_margins" android:layout_marginRight="@dimen/btn_fab_margins" android:background="@drawable/btn_fab_default" android:elevation="@dimen/default_elevation" android:src="@drawable/ic_instagram_white" android:textSize="28sp" /> </RelativeLayout>
以上代码的解释:
- 关于
Toolbar
最重要的特征是他现在是activity layout的一部分,而且继承自ViewGroup,因此我们可以在里面放一些UI元素(它们将利用剩余空间)。本例中,它被用来放置logo图片。同时,因为Toolbar
是比Actionbar更灵活的控件,我们可以自定义更多的东西,比如设置背景颜色为colorPrimary
(否则Toolbar将是透明的)。 RecyclerView
虽然在xml中用起来非常简单,但是如果java代码中没有设置正确,app是不能启动的,会报java.lang.NullPointerException
。
- Elevation(ImageButton中)属性不兼容api21以前的版本。所以如果我们想做到Floating Action Button的效果需要在Lollipop和Lollipop之前的设备上使用不同的background。
Floating Action Button
为了简化FAB的使用,我们将用对Lollipop以及Lollipop之前的设备使用不同的样式:
FAB for Android v21:
FAB for Android pre-21
我们需要创建两个不同的xml文件来设置button的background:/res/drawable-v21/btn_fab_default.xml
(Lollipop设备) ,/res/drawable/btn_fab_default.xml(
Lollipop
之前的设备):
btn_fab_default2.xml
<?xml version="1.0" encoding="utf-8"?> <!--drawable-v21/btn_fab_default.xml--> <ripple xmlns:android="http://schemas.android.com/apk/res/android" android:color="@color/fab_color_shadow"> <item> <shape android:shape="oval"> <solid android:color="@color/style_color_accent" /> </shape> </item> </ripple>
btn_fab_default1.xml
<?xml version="1.0" encoding="utf-8"?> <!--drawable/btn_fab_default.xml--> <selector xmlns:android="http://schemas.android.com/apk/res/android"> <item android:state_pressed="false"> <layer-list> <item android:bottom="0dp" android:left="2dp" android:right="2dp" android:top="2dp"> <shape android:shape="oval"> <solid android:color="@color/fab_color_shadow" /> </shape> </item> <item android:bottom="2dp" android:left="2dp" android:right="2dp" android:top="2dp"> <shape android:shape="oval"> <solid android:color="@color/style_color_accent" /> </shape> </item> </layer-list> </item> <item android:state_pressed="true"> <shape android:bottom="2dp" android:left="2dp" android:right="2dp" android:shape="oval" android:top="2dp"> <solid android:color="@color/fab_color_pressed" /> </shape> </item> </selector>
上面的代码涉及到两个颜色的定义,在res/values/colors.xml
中添加:
<color name="btn_default_light_normal">#00000000</color> <color name="btn_default_light_pressed">#40ffffff</color>
可以看到在 21之前的设备商制造阴影比较复杂。不幸的是在xml中达到真实的阴影效果没有渐变方法。其他的办法是使用图片的方式,或者通过java代码实现(参见creating fab shadow)。
Toolbar
现在我们来完成Toolbar。我们已经有了background和应用的logo,现在还剩下navigation以及menu菜单图标了。关于navigation,非常不幸的是,在xml中app:navigationIcon=""是不起作用的,而android:navigationIcon=""又只能在Lollipop上有用,所以只能使用代码的方式了:
toolbar.setNavigationIcon(R.drawable.ic_menu_white);
注:app:navigationIcon=""的意思是使用兼容包appcompat的属性,而android:navigationIcon=""是标准的sdk属性。
至于menu图标我们使用标准的定义方式就好了:
在res/menu/menu_main.xml中
<!--menu_main.xml--> <menu xmlns:android="http://schemas.android.com/apk/res/android" xmlns:app="http://schemas.android.com/apk/res-auto" xmlns:tools="http://schemas.android.com/tools" tools:context=".MainActivity"> <item android:id="@+id/action_inbox" android:icon="@drawable/ic_inbox_white" android:title="Inbox" app:showAsAction="always" /> </menu>
在activity中inflated这个menu:
@Override public boolean onCreateOptionsMenu(Menu menu) { getMenuInflater().inflate(R.menu.menu_main, menu); return true; }
本应运行的很好,但是正如我在twitter上提到的,Toolbar onClick selectors有不协调的情况:
为了解决这个问题,需要做更多的工作,首先为menu item创建一个自定义的view
res/layout/menu_item_view.xml
:
<?xml version="1.0" encoding="utf-8"?> <!--menu_item_view.xml--> <ImageButton xmlns:android="http://schemas.android.com/apk/res/android" android:layout_width="?attr/actionBarSize" android:layout_height="?attr/actionBarSize" android:background="@drawable/btn_default_light" android:src="@drawable/ic_inbox_white" />
然后为Lollipop和Lollipop之前的设备分别创建onClick的selector,在Lollipop上有ripple效果:
btn_default_light2.xml
<?xml version="1.0" encoding="utf-8"?> <!--drawable-v21/btn_default_light.xml--> <ripple xmlns:android="http://schemas.android.com/apk/res/android" android:color="@color/btn_default_light_pressed" />
btn_default_light1.xml
<?xml version="1.0" encoding="utf-8"?> <!--drawable/btn_default_light.xml--> <selector xmlns:android="http://schemas.android.com/apk/res/android"> <item android:drawable="@color/btn_default_light_normal" android:state_focused="false" android:state_pressed="false" /> <item android:drawable="@color/btn_default_light_pressed" android:state_pressed="true" /> <item android:drawable="@color/btn_default_light_pressed" android:state_focused="true" /> </selector>
现在,工程中的所有的color应该是这样子了:
colors.xml
<?xml version="1.0" encoding="utf-8"?> <!--colors.xml--> <resources> <color name="style_color_primary">#2d5d82</color> <color name="style_color_primary_dark">#21425d</color> <color name="style_color_accent">#01bcd5</color> <color name="fab_color_pressed">#007787</color> <color name="fab_color_shadow">#44000000</color> <color name="btn_default_light_normal">#00000000</color> <color name="btn_default_light_pressed">#40ffffff</color> </resources>
最后我们应该将custom view放到menu item中,在onCreateOptionsMenu()
中:
@Override public boolean onCreateOptionsMenu(Menu menu) { getMenuInflater().inflate(R.menu.menu_main, menu); inboxMenuItem = menu.findItem(R.id.action_inbox); inboxMenuItem.setActionView(R.layout.menu_item_view); return true; }
以上就是toolbar的所有东西。并且onClick的按下效果也达到了预期的效果:
Feed
Last thing we should implement is feed, built on RecyclerView
. Right now we have to setup two things: layout manager (RecyclerView has to know how to arrange items) and adapter (to provide items).
First thing is straightforward - while our layout is simple ListView we can useLinearLayoutManager
for items arragement. For the second one we have to do more work, buth there is no magic to deal with.
Let’s start from defining list item layout (res/layout/item_feed.xml
):
最后需要实现的是feed,基于RecyclerView
实现。我们需要设置两个东西:layout manager和adapter,因为这里其实就是想实现ListView的效果,所以直接用LinearLayoutManager
就行了,而adapter我们首先从item的布局开始(res/layout/item_feed.xml
):
item_feed.xml
<?xml version="1.0" encoding="utf-8"?><!-- item_feed.xml --> <android.support.v7.widget.CardView xmlns:android="http://schemas.android.com/apk/res/android" xmlns:card_view="http://schemas.android.com/apk/res-auto" android:id="@+id/card_view" android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_gravity="center" android:layout_margin="8dp" card_view:cardCornerRadius="4dp"> <LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="vertical"> <ImageView android:layout_width="match_parent" android:layout_height="wrap_content" android:src="@drawable/ic_feed_top" /> <io.github.froger.instamaterial.SquaredImageView android:id="@+id/ivFeedCenter" android:layout_width="match_parent" android:layout_height="wrap_content" /> <ImageView android:id="@+id/ivFeedBottom" android:layout_width="match_parent" android:layout_height="wrap_content" /> </LinearLayout> </android.support.v7.widget.CardView>
FeedAdapter
也非常简单:
FeedAdapter.java
public class FeedAdapter extends RecyclerView.Adapter<RecyclerView.ViewHolder> { private static final int ANIMATED_ITEMS_COUNT = 2; private Context context; private int lastAnimatedPosition = -1; private int itemsCount = 0; public FeedAdapter(Context context) { this.context = context; } @Override public RecyclerView.ViewHolder onCreateViewHolder(ViewGroup parent, int viewType) { final View view = LayoutInflater.from(context).inflate(R.layout.item_feed, parent, false); return new CellFeedViewHolder(view); } private void runEnterAnimation(View view, int position) { if (position >= ANIMATED_ITEMS_COUNT - 1) { return; } if (position > lastAnimatedPosition) { lastAnimatedPosition = position; view.setTranslationY(Utils.getScreenHeight(context)); view.animate() .translationY(0) .setInterpolator(new DecelerateInterpolator(3.f)) .setDuration(700) .start(); } } @Override public void onBindViewHolder(RecyclerView.ViewHolder viewHolder, int position) { runEnterAnimation(viewHolder.itemView, position); CellFeedViewHolder holder = (CellFeedViewHolder) viewHolder; if (position % 2 == 0) { holder.ivFeedCenter.setImageResource(R.drawable.img_feed_center_1); holder.ivFeedBottom.setImageResource(R.drawable.img_feed_bottom_1); } else { holder.ivFeedCenter.setImageResource(R.drawable.img_feed_center_2); holder.ivFeedBottom.setImageResource(R.drawable.img_feed_bottom_2); } } @Override public int getItemCount() { return itemsCount; } public static class CellFeedViewHolder extends RecyclerView.ViewHolder { @InjectView(R.id.ivFeedCenter) SquaredImageView ivFeedCenter; @InjectView(R.id.ivFeedBottom) ImageView ivFeedBottom; public CellFeedViewHolder(View view) { super(view); ButterKnife.inject(this, view); } } public void updateItems() { itemsCount = 10; notifyDataSetChanged(); } }
没什么特别之处需要说明。
通过以下方法将他们放在一起:
private void setupFeed() { LinearLayoutManager linearLayoutManager = new LinearLayoutManager(this); rvFeed.setLayoutManager(linearLayoutManager); feedAdapter = new FeedAdapter(this); rvFeed.setAdapter(feedAdapter); }
下面是整个MainActivity class的源码:
//MainActivity.java public class MainActivity extends ActionBarActivity { @InjectView(R.id.toolbar) Toolbar toolbar; @InjectView(R.id.rvFeed) RecyclerView rvFeed; private MenuItem inboxMenuItem; private FeedAdapter feedAdapter; @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.activity_main); ButterKnife.inject(this); setupToolbar(); setupFeed(); } private void setupToolbar() { setSupportActionBar(toolbar); toolbar.setNavigationIcon(R.drawable.ic_menu_white); } private void setupFeed() { LinearLayoutManager linearLayoutManager = new LinearLayoutManager(this); rvFeed.setLayoutManager(linearLayoutManager); feedAdapter = new FeedAdapter(this); rvFeed.setAdapter(feedAdapter); } @Override public boolean onCreateOptionsMenu(Menu menu) { getMenuInflater().inflate(R.menu.menu_main, menu); inboxMenuItem = menu.findItem(R.id.action_inbox); inboxMenuItem.setActionView(R.layout.menu_item_view); return true; } }
运行结果:
Android Lollipop
Android pre-21
动画
最后一件也是最重要的事情就是进入时的动画效果,再浏览一遍概念视频,可以发现在main Activity启动的时候有如下动画,分成两步:
显示Toolbar以及其里面的元素
在Toolbar动画完成之后显示feed和floating action button。
Toolbar中元素的动画表现为在较短的时间内一个接一个的进入。实现这个效果的主要问题在于navigation icon的动画,navigation icon是唯一一个不能使用动画的,其他的都好办。
Toolbar animation
首先我们只是需要在activity启动的时候才播放动画(在旋转屏幕的时候不播放),还要知道menu的动画过程是不能在onCreate()
中去实现的(我们在onCreateOptionsMenu()
中实现),创建一个布尔类型的变量pendingIntroAnimation
,在onCreate
()方法中初始化:
//... if (savedInstanceState == null) { pendingIntroAnimation = true; }
onCreateOptionsMenu()
:
@Override public boolean onCreateOptionsMenu(Menu menu) { getMenuInflater().inflate(R.menu.menu_main, menu); inboxMenuItem = menu.findItem(R.id.action_inbox); inboxMenuItem.setActionView(R.layout.menu_item_view); if (pendingIntroAnimation) { pendingIntroAnimation = false; startIntroAnimation(); } return true; }
这样startIntroAnimation()
将只被调用一次。
现在该来准备Toolbar中元素的动画了,也非常简单
ToolbarAnimation
//... private static final int ANIM_DURATION_TOOLBAR = 300; private void startIntroAnimation() { btnCreate.setTranslationY(2 * getResources().getDimensionPixelOffset(R.dimen.btn_fab_size)); int actionbarSize = Utils.dpToPx(56); toolbar.setTranslationY(-actionbarSize); ivLogo.setTranslationY(-actionbarSize); inboxMenuItem.getActionView().setTranslationY(-actionbarSize); toolbar.animate() .translationY(0) .setDuration(ANIM_DURATION_TOOLBAR) .setStartDelay(300); ivLogo.animate() .translationY(0) .setDuration(ANIM_DURATION_TOOLBAR) .setStartDelay(400); inboxMenuItem.getActionView().animate() .translationY(0) .setDuration(ANIM_DURATION_TOOLBAR) .setStartDelay(500) .setListener(new AnimatorListenerAdapter() { @Override public void onAnimationEnd(Animator animation) { startContentAnimation(); } }) .start(); } //...
在上面的代码中:
- 首先我们将所有的元素都通过移动到屏幕之外隐藏起来(这一步我们将FAB也隐藏了)。
- 让Toolbar元素一个接一个的开始动画
- 当动画完成,调用了startContentAnimation()开始content的动画(FAB和feed卡片的动画)
简单,是吧?
Content 动画
在这一步中我们将让FAB和feed卡片动起来。FAB的动画很简单,跟上面的方法类似,但是feed卡片稍微复杂些。
startContentAnimation
方法
//... //FAB animation private static final int ANIM_DURATION_FAB = 400; private void startContentAnimation() { btnCreate.animate() .translationY(0) .setInterpolator(new OvershootInterpolator(1.f)) .setStartDelay(300) .setDuration(ANIM_DURATION_FAB) .start(); feedAdapter.updateItems(); } //...
FeedAdapter
的代码在上面已经贴出来了。结合着就知道动画是如何实现的了。
本篇文章就结束了,避免遗漏,这里是这篇文章是提交的代码commit for our project with implemented animations.
源代码
完整的代码在Github repository.
作者: Miroslaw Stanek
英文原文:Instagram with Material Design concept is getting real
还发表在这里:http://jcodecraeer.com/a/anzhuokaifa/androidkaifa/2015/0204/2415.html