这篇文章讲解如何使用RecyclerView实现Gmail收件箱界面的效果。
1.概述
Gmail app的收件箱界面并不光是用RecyclerView实现的,还需要结合其它的view。总的来说我们将使用以下的控件来实现界面和功能。
> RecyclerView
RecyclerView是这所需的最基本控件。我们用它来显示头像,三行消息,时间,star图标(将消息标记为重要)。
> SwipeRefreshLayout
SwipeRefreshLayout用来包裹RecyclerView,实现下拉刷新。
> ActionMode
ActionMode 用于在长按item的时候显示上下文菜单(toolbar)。它可以让我们在RecyclerView处于多选模式的时候展示不同图标的toolbar。这里我们提供的是一个删除菜单。
> Object Animators
Object Animators 用于对目标元素做动画。这里我们使用属性动画来执行长按之后缩略图的翻转动画。
> Retrofit
真实的app中,所有的消息都是动态的,比如从一个REST API获取的数据。为此我们使用了一个 JSON url来模拟数据。我们使用 Retrofit 库来获取和解析JSON。
我在后端创建了一个返回JSON格式数据的API。这个JSON包含了头像,来源地,主题,消息,时间戳以及其它的信息。真实场景中这些数据都是使用服务端语言从数据库中取出来的。
http://api.androidhive.info/json/inbox.json
[ { "id": 1, "isImportant": false, "picture": "http://api.androidhive.info/json/google.png", "from": "Google Alerts", "subject": "Google Alert - android", "message": "Android N update is released to Nexus Family!", "timestamp": "10:30 AM", "isRead": false }, . . . ]
我们从新建一个项目开始,然后做基本的设置。下面是项目的代码结构:
1.创建的时候,我们选择BasicActivity作为默认的activity,以便获得Toolbar, FAB等元素。
2. 在app module下的 build.gradle中添加RecyclerView, Retrofit 以及 Glide 的依赖,然后Sync项目。
dependencies { compile fileTree(dir: 'libs', include: ['*.jar']) androidTestCompile('com.android.support.test.espresso:espresso-core:2.2.2', { exclude group: 'com.android.support', module: 'support-annotations' }) compile 'com.android.support:appcompat-v7:24.2.1' compile 'com.android.support.constraint:constraint-layout:1.0.0-alpha7' compile 'com.android.support:design:24.2.1' testCompile 'junit:junit:4.12' // RecyclerView compile 'com.android.support:recyclerview-v7:24.2.1' // retrofit, gson compile 'com.google.code.gson:gson:2.6.2' compile 'com.squareup.retrofit2:retrofit:2.0.2' compile 'com.squareup.retrofit2:converter-gson:2.0.2' // glide compile 'com.github.bumptech.glide:glide:3.7.0' }
3. 下载这个 res 文件夹,并把它里面的内容复制到你项目的res目录中。里面包含了RecyclerView和Toolbar所需的所有资源文件。
4. 在相应的文件中添加下面的color,string和dimen
colors.xml
colors.xml <?xml version="1.0" encoding="utf-8"?> <resources> <color name="colorPrimary">#db4437</color> <color name="colorPrimaryDark">#b93221</color> <color name="colorAccent">#FFFFFF</color> <color name="from">#000000</color> <color name="subject">#111111</color> <color name="timestamp">#4285f4</color> <color name="message">#7a7a7a</color> <color name="icon_tint_normal">#7a7a7a</color> <color name="icon_tint_selected">#fed776</color> <color name="row_activated">#e0e0e0</color> <color name="bg_action_mode">#757575</color> <color name="bg_circle_default">#666666</color> </resources>
dimens.xml
dimens.xml <resources> <dimen name="fab_margin">16dp</dimen> <dimen name="padding_list_row">16dp</dimen> <dimen name="messages_padding_left">72dp</dimen> <dimen name="icon_width_height">40dp</dimen> <dimen name="msg_text_primary">16sp</dimen> <dimen name="msg_text_secondary">14sp</dimen> <dimen name="icon_star">25dp</dimen> <dimen name="icon_text">22dp</dimen> <dimen name="timestamp">12dp</dimen> </resources>
strings.xml
strings.xml <resources> <string name="app_name">Gmail</string> <string name="action_settings">Settings</string> <string name="action_search">Search</string> <string name="action_delete">Delete</string> </resources>
5. 打开 styles.xml 并添加如下的styles。这里的 windowActionModeOverlay是为了让ActionMode叠加在Toolbar上面。
styles.xml <resources> <!-- Base application theme. --> <style name="AppTheme" parent="Theme.AppCompat.Light.DarkActionBar"> <!-- Customize your theme here. --> <item name="colorPrimary">@color/colorPrimary</item> <item name="colorPrimaryDark">@color/colorPrimaryDark</item> <item name="colorAccent">@color/colorAccent</item> </style> <style name="AppTheme.NoActionBar"> <item name="windowActionBar">false</item> <item name="windowNoTitle">true</item> <item name="windowActionModeOverlay">true</item> <item name="android:actionModeBackground">@color/bg_action_mode</item> </style> <style name="AppTheme.AppBarOverlay" parent="ThemeOverlay.AppCompat.Dark.ActionBar" /> <style name="AppTheme.PopupOverlay" parent="ThemeOverlay.AppCompat.Light" /> </resources>
6. 因为要使用网络,因此需要在manifest中申请权限。打开AndroidManifest.xml添加权限。
AndroidManifest.xml <?xml version="1.0" encoding="utf-8"?> <manifest xmlns:android="http://schemas.android.com/apk/res/android" package="info.androidhive.gmail"> <uses-permission android:name="android.permission.INTERNET" /> <application android:allowBackup="true" android:icon="@mipmap/ic_launcher" android:label="@string/app_name" android:supportsRtl="true" android:theme="@style/AppTheme"> <activity android:name=".activity.MainActivity" android:label="@string/app_name" android:theme="@style/AppTheme.NoActionBar"> <intent-filter> <action android:name="android.intent.action.MAIN" /> <category android:name="android.intent.category.LAUNCHER" /> </intent-filter> </activity> </application> </manifest>
7. 创建5个包,分别命名 activity, adapter, helper, model and network。创建好了之后,把MainActivity移动到activity包之下。
现在项目的基本资源就准备好了。我们使用Retrofit来处理网络层。如果你对Retrofit不熟悉,强烈推荐看一遍我关于Retrofit的前一篇文章。
8. 在model在model包中,创建名为 Message.java的类。这个类解析时反序列化json。
Message.java package info.androidhive.gmail.model; public class Message { private int id; private String from; private String subject; private String message; private String timestamp; private String picture; private boolean isImportant; private boolean isRead; private int color = -1; public Message() { } public int getId() { return id; } public void setId(int id) { this.id = id; } public String getFrom() { return from; } public void setFrom(String from) { this.from = from; } public String getSubject() { return subject; } public void setSubject(String subject) { this.subject = subject; } public String getMessage() { return message; } public void setMessage(String message) { this.message = message; } public String getTimestamp() { return timestamp; } public void setTimestamp(String timestamp) { this.timestamp = timestamp; } public boolean isImportant() { return isImportant; } public void setImportant(boolean important) { isImportant = important; } public String getPicture() { return picture; } public void setPicture(String picture) { this.picture = picture; } public boolean isRead() { return isRead; } public void setRead(boolean read) { isRead = read; } public int getColor() { return color; } public void setColor(int color) { this.color = color; } }
9. 在network包下面,创建一个名为 ApiClient.java的类。这个类用于创建静态的retrofit实例。
ApiClient.java package info.androidhive.gmail.network; import retrofit2.Retrofit; import retrofit2.converter.gson.GsonConverterFactory; public class ApiClient { public static final String BASE_URL = "http://api.androidhive.info/json/"; private static Retrofit retrofit = null; public static Retrofit getClient() { if (retrofit == null) { retrofit = new Retrofit.Builder() .baseUrl(BASE_URL) .addConverterFactory(GsonConverterFactory.create()) .build(); } return retrofit; } }
10. 在network包下,创建ApiInterface.java类。这个类包含了请求的接口,这里我们只有一个inbox.json接口。
ApiInterface.java package info.androidhive.gmail.network; import java.util.List; import info.androidhive.gmail.model.Message; import retrofit2.Call; import retrofit2.http.GET; public interface ApiInterface { @GET("inbox.json") Call<List<Message>> getInbox(); }
这就完成了retrofit的集成。现在我们添加一些helper类来帮助渲染list。
11. 在helper包下,创建一个名为CircleTransform.java的类。这个类用Glide显示圆形的缩略图。
CircleTransform.java package info.androidhive.gmail.helper; import android.content.Context; import android.graphics.Bitmap; import android.graphics.BitmapShader; import android.graphics.Canvas; import android.graphics.Paint; import com.bumptech.glide.load.engine.bitmap_recycle.BitmapPool; import com.bumptech.glide.load.resource.bitmap.BitmapTransformation; public class CircleTransform extends BitmapTransformation { public CircleTransform(Context context) { super(context); } @Override protected Bitmap transform(BitmapPool pool, Bitmap toTransform, int outWidth, int outHeight) { return circleCrop(pool, toTransform); } private static Bitmap circleCrop(BitmapPool pool, Bitmap source) { if (source == null) return null; int size = Math.min(source.getWidth(), source.getHeight()); int x = (source.getWidth() - size) / 2; int y = (source.getHeight() - size) / 2; // TODO this could be acquired from the pool too Bitmap squared = Bitmap.createBitmap(source, x, y, size, size); Bitmap result = pool.get(size, size, Bitmap.Config.ARGB_8888); if (result == null) { result = Bitmap.createBitmap(size, size, Bitmap.Config.ARGB_8888); } Canvas canvas = new Canvas(result); Paint paint = new Paint(); paint.setShader(new BitmapShader(squared, BitmapShader.TileMode.CLAMP, BitmapShader.TileMode.CLAMP)); paint.setAntiAlias(true); float r = size / 2f; canvas.drawCircle(r, r, r, paint); return result; } @Override public String getId() { return getClass().getName(); } }
12.包之下,创建另一个名为DividerItemDecoration.java的类。为recycler view添加分割线。
DividerItemDecoration package info.androidhive.gmail.helper; import android.content.Context; import android.content.res.TypedArray; import android.graphics.Canvas; import android.graphics.Rect; import android.graphics.drawable.Drawable; import android.support.v7.widget.LinearLayoutManager; import android.support.v7.widget.RecyclerView; import android.view.View; /** * Created by Ravi Tamada on 21/02/17. * www.androidhive.info */ public class DividerItemDecoration extends RecyclerView.ItemDecoration { private static final int[] ATTRS = new int[]{ android.R.attr.listDivider }; public static final int HORIZONTAL_LIST = LinearLayoutManager.HORIZONTAL; public static final int VERTICAL_LIST = LinearLayoutManager.VERTICAL; private Drawable mDivider; private int mOrientation; public DividerItemDecoration(Context context, int orientation) { final TypedArray a = context.obtainStyledAttributes(ATTRS); mDivider = a.getDrawable(0); a.recycle(); setOrientation(orientation); } public void setOrientation(int orientation) { if (orientation != HORIZONTAL_LIST && orientation != VERTICAL_LIST) { throw new IllegalArgumentException("invalid orientation"); } mOrientation = orientation; } @Override public void onDrawOver(Canvas c, RecyclerView parent, RecyclerView.State state) { if (mOrientation == VERTICAL_LIST) { drawVertical(c, parent); } else { drawHorizontal(c, parent); } } public void drawVertical(Canvas c, RecyclerView parent) { final int left = parent.getPaddingLeft(); final int right = parent.getWidth() - parent.getPaddingRight(); final int childCount = parent.getChildCount(); for (int i = 0; i < childCount; i++) { final View child = parent.getChildAt(i); final RecyclerView.LayoutParams params = (RecyclerView.LayoutParams) child .getLayoutParams(); final int top = child.getBottom() + params.bottomMargin; final int bottom = top + mDivider.getIntrinsicHeight(); mDivider.setBounds(left, top, right, bottom); mDivider.draw(c); } } public void drawHorizontal(Canvas c, RecyclerView parent) { final int top = parent.getPaddingTop(); final int bottom = parent.getHeight() - parent.getPaddingBottom(); final int childCount = parent.getChildCount(); for (int i = 0; i < childCount; i++) { final View child = parent.getChildAt(i); final RecyclerView.LayoutParams params = (RecyclerView.LayoutParams) child .getLayoutParams(); final int left = child.getRight() + params.rightMargin; final int right = left + mDivider.getIntrinsicHeight(); mDivider.setBounds(left, top, right, bottom); mDivider.draw(c); } } @Override public void getItemOffsets(Rect outRect, View view, RecyclerView parent, RecyclerView.State state) { if (mOrientation == VERTICAL_LIST) { outRect.set(0, 0, 0, mDivider.getIntrinsicHeight()); } else { outRect.set(0, 0, mDivider.getIntrinsicWidth(), 0); } } }
这里的另一个有趣的事情是,为每行的图标设置一个随机的背景色。为此我们需要预先定义一套material color的数组,然后然后在RecyclerView准备好的时候随机的选择一个颜色。感谢daniellevass提供这些颜色代码。
13. 在res ⇒ values下创建array.xml 。这个xml包含了将要在list中随机加载的 material color。
array.xml <?xml version="1.0" encoding="utf-8"?> <resources> <array name="mdcolor_400"> <item name="red_400" type="color">#e84e40</item> <item name="pink_400" type="color">#ec407a</item> <item name="purple_400" type="color">#ab47bc</item> <item name="deep_purple_400" type="color">#7e57c2</item> <item name="indigo_400" type="color">#5c6bc0</item> <item name="blue_400" type="color">#738ffe</item> <item name="light_blue_400" type="color">#29b6f6</item> <item name="cyan_400" type="color">#26c6da</item> <item name="teal_400" type="color">#26a69a</item> <item name="green_400" type="color">#2baf2b</item> <item name="light_green_400" type="color">#9ccc65</item> <item name="lime_400" type="color">#d4e157</item> <item name="yellow_400" type="color">#ffee58</item> <item name="orange_400" type="color">#ffa726</item> <item name="deep_orange_400" type="color">#ff7043</item> <item name="brown_400" type="color">#8d6e63</item> <item name="grey_400" type="color">#bdbdbd</item> <item name="blue_grey_400" type="color">#78909c</item> </array> <array name="mdcolor_500"> <item name="red_500" type="color">#e51c23</item> <item name="pink_500" type="color">#e91e63</item> <item name="purple_500" type="color">#9c27b0</item> <item name="deep_purple_500" type="color">#673ab7</item> <item name="indigo_500" type="color">#3f51b5</item> <item name="blue_500" type="color">#5677fc</item> <item name="light_blue_500" type="color">#03a9f4</item> <item name="cyan_500" type="color">#00bcd4</item> <item name="teal_500" type="color">#009688</item> <item name="green_500" type="color">#259b24</item> <item name="light_green_500" type="color">#8bc34a</item> <item name="lime_500" type="color">#cddc39</item> <item name="yellow_500" type="color">#ffeb3b</item> <item name="orange_500" type="color">#ff9800</item> <item name="deep_orange_500" type="color">#ff5722</item> <item name="brown_500" type="color">#795548</item> <item name="grey_500" type="color">#9e9e9e</item> <item name="blue_grey_500" type="color">#607d8b</item> </array> </resources>
要随机加载这些颜色,可以使用下面的函数。马上你就可以看到如何使用这个函数。
private int getRandomMaterialColor(String typeColor) { int returnColor = Color.GRAY; int arrayId = getResources().getIdentifier("mdcolor_" + typeColor, "array", getPackageName()); if (arrayId != 0) { TypedArray colors = getResources().obtainTypedArray(arrayId); int index = (int) (Math.random() * colors.length()); returnColor = colors.getColor(index, Color.GRAY); colors.recycle(); } return returnColor; }
如果你观察gmail应用,当你长按一行的时候,缩略图图标会显示一个翻转动画,显示图标的另一面。我们可以使用ObjectAnimator做同样的事情。在你的项目中仔细创建下面提到的文件。
14.在res ⇒ values下,创建一个 integer.xml。我们在这里定义动画的持续时间。
integer.xml <?xml version="1.0" encoding="utf-8"?> <resources> <integer name="card_flip_time_full">500</integer> <integer name="card_flip_time_half">200</integer> </resources>
15.在res目录下创建一个名为animator的目录。在这个目录中我们存放与动画相关的所有xml资源。
16. 在animator目录下,我们创建card_flip_left_in.xml, card_flip_left_out.xml, card_flip_right_in.xml and card_flip_right_out.xml。card_flip_left_in.xml
card_flip_left_in.xml <set xmlns:android="http://schemas.android.com/apk/res/android"> <!-- Before rotating, immediately set the alpha to 0. --> <objectAnimator android:valueFrom="1.0" android:valueTo="0.0" android:propertyName="alpha" android:duration="0" /> <!-- Rotate. --> <objectAnimator android:valueFrom="-180" android:valueTo="0" android:propertyName="rotationY" android:interpolator="@android:interpolator/accelerate_decelerate" android:duration="@integer/card_flip_time_full" /> <!-- Half-way through the rotation (see startOffset), set the alpha to 1. --> <objectAnimator android:valueFrom="0.0" android:valueTo="1.0" android:propertyName="alpha" android:startOffset="@integer/card_flip_time_half" android:duration="1" /> </set>
card_flip_left_out.xml
card_flip_left_out.xml <set xmlns:android="http://schemas.android.com/apk/res/android"> <!-- Rotate. --> <objectAnimator android:valueFrom="0" android:valueTo="180" android:propertyName="rotationY" android:interpolator="@android:interpolator/accelerate_decelerate" android:duration="@integer/card_flip_time_full" /> <!-- Half-way through the rotation (see startOffset), set the alpha to 0. --> <objectAnimator android:valueFrom="1.0" android:valueTo="0.0" android:propertyName="alpha" android:startOffset="@integer/card_flip_time_half" android:duration="1" /> </set>
card_flip_right_in.xml
card_flip_right_in.xml <set xmlns:android="http://schemas.android.com/apk/res/android"> <!-- Before rotating, immediately set the alpha to 0. --> <objectAnimator android:valueFrom="1.0" android:valueTo="0.0" android:propertyName="alpha" android:duration="0" /> <!-- Rotate. --> <objectAnimator android:valueFrom="180" android:valueTo="0" android:propertyName="rotationY" android:interpolator="@android:interpolator/accelerate_decelerate" android:duration="@integer/card_flip_time_full" /> <!-- Half-way through the rotation (see startOffset), set the alpha to 1. --> <objectAnimator android:valueFrom="0.0" android:valueTo="1.0" android:propertyName="alpha" android:startOffset="@integer/card_flip_time_half" android:duration="1" /> </set>
card_flip_right_out.xml
card_flip_right_out.xml <set xmlns:android="http://schemas.android.com/apk/res/android"> <!-- Rotate. --> <objectAnimator android:valueFrom="0" android:valueTo="-180" android:propertyName="rotationY" android:interpolator="@android:interpolator/accelerate_decelerate" android:duration="@integer/card_flip_time_full" /> <!-- Half-way through the rotation (see startOffset), set the alpha to 0. --> <objectAnimator android:valueFrom="1.0" android:valueTo="0.0" android:propertyName="alpha" android:startOffset="@integer/card_flip_time_half" android:duration="1" /> </set>
17. 在helper包下,创建一个名为FlipAnimator.java的类。这个类有一个执行翻转动画的静态方法 flipView() 。
FlipAnimator.java package info.androidhive.gmail.helper; import android.animation.AnimatorInflater; import android.animation.AnimatorSet; import android.content.Context; import android.view.View; import info.androidhive.gmail.R; public class FlipAnimator { private static String TAG = FlipAnimator.class.getSimpleName(); private static AnimatorSet leftIn, rightOut, leftOut, rightIn; /** * Performs flip animation on two views */ public static void flipView(Context context, final View back, final View front, boolean showFront) { leftIn = (AnimatorSet) AnimatorInflater.loadAnimator(context, R.animator.card_flip_left_in); rightOut = (AnimatorSet) AnimatorInflater.loadAnimator(context, R.animator.card_flip_right_out); leftOut = (AnimatorSet) AnimatorInflater.loadAnimator(context, R.animator.card_flip_left_out); rightIn = (AnimatorSet) AnimatorInflater.loadAnimator(context, R.animator.card_flip_right_in); final AnimatorSet showFrontAnim = new AnimatorSet(); final AnimatorSet showBackAnim = new AnimatorSet(); leftIn.setTarget(back); rightOut.setTarget(front); showFrontAnim.playTogether(leftIn, rightOut); leftOut.setTarget(back); rightIn.setTarget(front); showBackAnim.playTogether(rightIn, leftOut); if (showFront) { showFrontAnim.start(); } else { showBackAnim.start(); } } }
终于到了本文的关键部分-渲染列表。
现在让我们来创建几个RecyclerView所需要的文件。我们所需要的所有文件:main activity的布局文件,列表的item,背景drawable以及一个adapter类。
18. 在 res ⇒ drawable下,创建两个drawable资源,分别是bg_circle.xml 和 bg_list_row.xml。
bg_circle.xml (为缩略图提供背景色)
bg_circle.xml <?xml version="1.0" encoding="utf-8"?> <shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="oval"> <solid android:color="@color/bg_circle_default"/> <size android:width="120dp" android:height="120dp"/> </shape>
bg_list_row.xml (为列表item的普通状态和按下状态提供背景色)
bg_list_row.xml <?xml version="1.0" encoding="utf-8"?> <selector xmlns:android="http://schemas.android.com/apk/res/android"> <item android:drawable="@color/row_activated" android:state_activated="true" /> <item android:drawable="@android:color/transparent" /> </selector>
19. 打开main activity的布局文件 (content_main.xml),并添加RecyclerView。
activity_main.xml
activity_main.xml <?xml version="1.0" encoding="utf-8"?> <android.support.design.widget.CoordinatorLayout 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" android:layout_width="match_parent" android:layout_height="match_parent" tools:mContext="info.androidhive.gmail.activity.MainActivity"> <android.support.design.widget.AppBarLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:theme="@style/AppTheme.AppBarOverlay"> <android.support.v7.widget.Toolbar android:id="@+id/toolbar" android:layout_width="match_parent" android:layout_height="?attr/actionBarSize" android:background="?attr/colorPrimary" app:popupTheme="@style/AppTheme.PopupOverlay" /> </android.support.design.widget.AppBarLayout> <include layout="@layout/content_main" /> <android.support.design.widget.FloatingActionButton android:id="@+id/fab" android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_gravity="bottom|end" android:layout_margin="@dimen/fab_margin" app:backgroundTint="@color/colorPrimary" app:srcCompat="@drawable/ic_edit_white_24dp" /> </android.support.design.widget.CoordinatorLayout>
content_main.xml
content_main.xml <?xml version="1.0" encoding="utf-8"?> <android.support.constraint.ConstraintLayout 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" android:layout_width="match_parent" android:layout_height="match_parent" app:layout_behavior="@string/appbar_scrolling_view_behavior" tools:mContext="info.androidhive.gmail.activity.MainActivity" tools:showIn="@layout/activity_main"> <android.support.v4.widget.SwipeRefreshLayout android:id="@+id/swipe_refresh_layout" android:layout_width="match_parent" android:layout_height="wrap_content"> <android.support.v7.widget.RecyclerView android:id="@+id/recycler_view" android:layout_width="match_parent" android:layout_height="match_parent" android:scrollbars="vertical" /> </android.support.v4.widget.SwipeRefreshLayout> </android.support.constraint.ConstraintLayout>
20. 在res ⇒ layout下,用下面的代码创建一个message_list_row.xml。这个布局用于显示列表的行。
message_list_row.xml <?xml version="1.0" encoding="utf-8"?> <RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android" android:layout_width="match_parent" android:layout_height="wrap_content" android:background="@drawable/bg_list_row" android:clickable="true" android:focusable="true" android:orientation="vertical" android:paddingBottom="@dimen/padding_list_row" android:paddingLeft="?listPreferredItemPaddingLeft" android:paddingRight="?listPreferredItemPaddingRight" android:paddingTop="@dimen/padding_list_row"> <LinearLayout android:id="@+id/message_container" android:layout_width="wrap_content" android:layout_height="wrap_content" android:clickable="true" android:orientation="vertical" android:paddingLeft="72dp" android:paddingRight="@dimen/padding_list_row"> <TextView android:id="@+id/from" android:layout_width="match_parent" android:layout_height="wrap_content" android:ellipsize="end" android:lines="1" android:textColor="@color/from" android:textSize="@dimen/msg_text_primary" android:textStyle="bold" /> <TextView android:id="@+id/txt_primary" android:layout_width="match_parent" android:layout_height="wrap_content" android:ellipsize="end" android:lines="1" android:textColor="@color/subject" android:textSize="@dimen/msg_text_secondary" android:textStyle="bold" /> <TextView android:id="@+id/txt_secondary" android:layout_width="match_parent" android:layout_height="wrap_content" android:ellipsize="end" android:lines="1" android:textColor="@color/message" android:textSize="@dimen/msg_text_secondary" /> </LinearLayout> <RelativeLayout android:id="@+id/icon_container" android:layout_width="wrap_content" android:layout_height="wrap_content" android:orientation="vertical"> <RelativeLayout android:id="@+id/icon_back" android:layout_width="wrap_content" android:layout_height="wrap_content"> <ImageView android:layout_width="@dimen/icon_width_height" android:layout_height="@dimen/icon_width_height" android:src="@drawable/bg_circle" /> <ImageView android:layout_width="25dp" android:layout_height="wrap_content" android:layout_centerInParent="true" android:src="@drawable/ic_done_white_24dp" /> </RelativeLayout> <RelativeLayout android:id="@+id/icon_front" android:layout_width="wrap_content" android:layout_height="wrap_content"> <ImageView android:id="@+id/icon_profile" android:layout_width="@dimen/icon_width_height" android:layout_height="@dimen/icon_width_height" /> <TextView android:id="@+id/icon_text" android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_centerInParent="true" android:textColor="@android:color/white" android:textSize="@dimen/icon_text" /> </RelativeLayout> </RelativeLayout> <TextView android:id="@+id/timestamp" android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_alignParentRight="true" android:textColor="@color/timestamp" android:textSize="@dimen/timestamp" android:textStyle="bold" /> <ImageView android:id="@+id/icon_star" android:layout_width="@dimen/icon_star" android:layout_height="@dimen/icon_star" android:layout_alignParentBottom="true" android:layout_alignParentRight="true" android:tint="@color/icon_tint_normal" /> </RelativeLayout>
我们还有两个渲染toolbar图标的menu文件。一个用于显示正常状态下的Toolbar 图标。另一个用于显示ActionMode启用时的图标。
21. 在res ⇒ menu目录下,创建两个menu文件,分别为menu_main.xml 和 menu_action_mode.xml。
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:mContext="info.androidhive.gmail.activity.MainActivity"> <item android:id="@+id/action_search" android:icon="@drawable/ic_search_white_24dp" android:orderInCategory="100" android:title="@string/action_search" app:showAsAction="always" /> </menu>
menu_action_mode.xml
menu_action_mode.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:mContext="info.androidhive.gmail.activity.MainActivity"> <item android:id="@+id/action_delete" android:icon="@drawable/ic_delete_white_24dp" android:orderInCategory="100" android:title="@string/action_delete" app:showAsAction="always" /> </menu>
还有一个需要处理的类是adapter类,RecyclerView的功能完全取决于你如何高效的管理adapter类。
22.在adapter包下,创建 MessagesAdapter.java 然后拷贝下面的代码。这个类非常重要,花点时间来研究这段代码,左右的奇迹都发生在 onBindViewHolder() 方法中。
> applyReadStatus() 根据阅读状态决定是否设置粗体文字,未读状态粗体。
> applyImportant() – 如果消息标记为重要,star图标显示为黄色。
> applyIconAnimation() – 执行thumbnail图标的翻转动画。
> applyProfilePicture() – 显示头像图片/或者圆形的背景
MessagesAdapter.java package info.androidhive.gmail.adapter; import android.content.Context; import android.graphics.Typeface; import android.support.v4.content.ContextCompat; import android.support.v7.widget.RecyclerView; import android.text.TextUtils; import android.util.SparseBooleanArray; import android.view.HapticFeedbackConstants; import android.view.LayoutInflater; import android.view.View; import android.view.ViewGroup; import android.widget.ImageView; import android.widget.LinearLayout; import android.widget.RelativeLayout; import android.widget.TextView; import com.bumptech.glide.Glide; import com.bumptech.glide.load.engine.DiskCacheStrategy; import java.util.ArrayList; import java.util.List; import info.androidhive.gmail.R; import info.androidhive.gmail.helper.CircleTransform; import info.androidhive.gmail.helper.FlipAnimator; import info.androidhive.gmail.model.Message; public class MessagesAdapter extends RecyclerView.Adapter<MessagesAdapter.MyViewHolder> { private Context mContext; private List<Message> messages; private MessageAdapterListener listener; private SparseBooleanArray selectedItems; // array used to perform multiple animation at once private SparseBooleanArray animationItemsIndex; private boolean reverseAllAnimations = false; // index is used to animate only the selected row // dirty fix, find a better solution private static int currentSelectedIndex = -1; public class MyViewHolder extends RecyclerView.ViewHolder implements View.OnLongClickListener { public TextView from, subject, message, iconText, timestamp; public ImageView iconImp, imgProfile; public LinearLayout messageContainer; public RelativeLayout iconContainer, iconBack, iconFront; public MyViewHolder(View view) { super(view); from = (TextView) view.findViewById(R.id.from); subject = (TextView) view.findViewById(R.id.txt_primary); message = (TextView) view.findViewById(R.id.txt_secondary); iconText = (TextView) view.findViewById(R.id.icon_text); timestamp = (TextView) view.findViewById(R.id.timestamp); iconBack = (RelativeLayout) view.findViewById(R.id.icon_back); iconFront = (RelativeLayout) view.findViewById(R.id.icon_front); iconImp = (ImageView) view.findViewById(R.id.icon_star); imgProfile = (ImageView) view.findViewById(R.id.icon_profile); messageContainer = (LinearLayout) view.findViewById(R.id.message_container); iconContainer = (RelativeLayout) view.findViewById(R.id.icon_container); view.setOnLongClickListener(this); } @Override public boolean onLongClick(View view) { listener.onRowLongClicked(getAdapterPosition()); view.performHapticFeedback(HapticFeedbackConstants.LONG_PRESS); return true; } } public MessagesAdapter(Context mContext, List<Message> messages, MessageAdapterListener listener) { this.mContext = mContext; this.messages = messages; this.listener = listener; selectedItems = new SparseBooleanArray(); animationItemsIndex = new SparseBooleanArray(); } @Override public MyViewHolder onCreateViewHolder(ViewGroup parent, int viewType) { View itemView = LayoutInflater.from(parent.getContext()) .inflate(R.layout.message_list_row, parent, false); return new MyViewHolder(itemView); } @Override public void onBindViewHolder(final MyViewHolder holder, final int position) { Message message = messages.get(position); // displaying text view data holder.from.setText(message.getFrom()); holder.subject.setText(message.getSubject()); holder.message.setText(message.getMessage()); holder.timestamp.setText(message.getTimestamp()); // displaying the first letter of From in icon text holder.iconText.setText(message.getFrom().substring(0, 1)); // change the row state to activated holder.itemView.setActivated(selectedItems.get(position, false)); // change the font style depending on message read status applyReadStatus(holder, message); // handle message star applyImportant(holder, message); // handle icon animation applyIconAnimation(holder, position); // display profile image applyProfilePicture(holder, message); // apply click events applyClickEvents(holder, position); } private void applyClickEvents(MyViewHolder holder, final int position) { holder.iconContainer.setOnClickListener(new View.OnClickListener() { @Override public void onClick(View view) { listener.onIconClicked(position); } }); holder.iconImp.setOnClickListener(new View.OnClickListener() { @Override public void onClick(View view) { listener.onIconImportantClicked(position); } }); holder.messageContainer.setOnClickListener(new View.OnClickListener() { @Override public void onClick(View view) { listener.onMessageRowClicked(position); } }); holder.messageContainer.setOnLongClickListener(new View.OnLongClickListener() { @Override public boolean onLongClick(View view) { listener.onRowLongClicked(position); view.performHapticFeedback(HapticFeedbackConstants.LONG_PRESS); return true; } }); } private void applyProfilePicture(MyViewHolder holder, Message message) { if (!TextUtils.isEmpty(message.getPicture())) { Glide.with(mContext).load(message.getPicture()) .thumbnail(0.5f) .crossFade() .transform(new CircleTransform(mContext)) .diskCacheStrategy(DiskCacheStrategy.ALL) .into(holder.imgProfile); holder.imgProfile.setColorFilter(null); holder.iconText.setVisibility(View.GONE); } else { holder.imgProfile.setImageResource(R.drawable.bg_circle); holder.imgProfile.setColorFilter(message.getColor()); holder.iconText.setVisibility(View.VISIBLE); } } private void applyIconAnimation(MyViewHolder holder, int position) { if (selectedItems.get(position, false)) { holder.iconFront.setVisibility(View.GONE); resetIconYAxis(holder.iconBack); holder.iconBack.setVisibility(View.VISIBLE); holder.iconBack.setAlpha(1); if (currentSelectedIndex == position) { FlipAnimator.flipView(mContext, holder.iconBack, holder.iconFront, true); resetCurrentIndex(); } } else { holder.iconBack.setVisibility(View.GONE); resetIconYAxis(holder.iconFront); holder.iconFront.setVisibility(View.VISIBLE); holder.iconFront.setAlpha(1); if ((reverseAllAnimations && animationItemsIndex.get(position, false)) || currentSelectedIndex == position) { FlipAnimator.flipView(mContext, holder.iconBack, holder.iconFront, false); resetCurrentIndex(); } } } // As the views will be reused, sometimes the icon appears as // flipped because older view is reused. Reset the Y-axis to 0 private void resetIconYAxis(View view) { if (view.getRotationY() != 0) { view.setRotationY(0); } } public void resetAnimationIndex() { reverseAllAnimations = false; animationItemsIndex.clear(); } @Override public long getItemId(int position) { return messages.get(position).getId(); } private void applyImportant(MyViewHolder holder, Message message) { if (message.isImportant()) { holder.iconImp.setImageDrawable(ContextCompat.getDrawable(mContext, R.drawable.ic_star_black_24dp)); holder.iconImp.setColorFilter(ContextCompat.getColor(mContext, R.color.icon_tint_selected)); } else { holder.iconImp.setImageDrawable(ContextCompat.getDrawable(mContext, R.drawable.ic_star_border_black_24dp)); holder.iconImp.setColorFilter(ContextCompat.getColor(mContext, R.color.icon_tint_normal)); } } private void applyReadStatus(MyViewHolder holder, Message message) { if (message.isRead()) { holder.from.setTypeface(null, Typeface.NORMAL); holder.subject.setTypeface(null, Typeface.NORMAL); holder.from.setTextColor(ContextCompat.getColor(mContext, R.color.subject)); holder.subject.setTextColor(ContextCompat.getColor(mContext, R.color.message)); } else { holder.from.setTypeface(null, Typeface.BOLD); holder.subject.setTypeface(null, Typeface.BOLD); holder.from.setTextColor(ContextCompat.getColor(mContext, R.color.from)); holder.subject.setTextColor(ContextCompat.getColor(mContext, R.color.subject)); } } @Override public int getItemCount() { return messages.size(); } public void toggleSelection(int pos) { currentSelectedIndex = pos; if (selectedItems.get(pos, false)) { selectedItems.delete(pos); animationItemsIndex.delete(pos); } else { selectedItems.put(pos, true); animationItemsIndex.put(pos, true); } notifyItemChanged(pos); } public void clearSelections() { reverseAllAnimations = true; selectedItems.clear(); notifyDataSetChanged(); } public int getSelectedItemCount() { return selectedItems.size(); } public List<Integer> getSelectedItems() { List<Integer> items = new ArrayList<>(selectedItems.size()); for (int i = 0; i < selectedItems.size(); i++) { items.add(selectedItems.keyAt(i)); } return items; } public void removeData(int position) { messages.remove(position); resetCurrentIndex(); } private void resetCurrentIndex() { currentSelectedIndex = -1; } public interface MessageAdapterListener { void onIconClicked(int position); void onIconImportantClicked(int position); void onMessageRowClicked(int position); void onRowLongClicked(int position); } }
23. 最后打开MainActivity.java,并修如下修改代码。
> 添加SwipeRefreshLayout实现刷新获取数据
> getInbox() Method获取并解析JSON,然后追加到array list中。
> 创建Adapter 并设置给RecyclerView。
> 长按item时启用ActionMode
MainActivity.java package info.androidhive.gmail.activity; import android.content.res.TypedArray; import android.graphics.Color; import android.os.Bundle; import android.support.design.widget.FloatingActionButton; import android.support.design.widget.Snackbar; import android.support.v4.widget.SwipeRefreshLayout; import android.support.v7.app.AppCompatActivity; import android.support.v7.view.ActionMode; import android.support.v7.widget.DefaultItemAnimator; import android.support.v7.widget.LinearLayoutManager; import android.support.v7.widget.RecyclerView; import android.support.v7.widget.Toolbar; import android.view.Menu; import android.view.MenuItem; import android.view.View; import android.widget.Toast; import java.util.ArrayList; import java.util.List; import info.androidhive.gmail.R; import info.androidhive.gmail.adapter.MessagesAdapter; import info.androidhive.gmail.helper.DividerItemDecoration; import info.androidhive.gmail.model.Message; import info.androidhive.gmail.network.ApiClient; import info.androidhive.gmail.network.ApiInterface; import retrofit2.Call; import retrofit2.Callback; import retrofit2.Response; public class MainActivity extends AppCompatActivity implements SwipeRefreshLayout.OnRefreshListener, MessagesAdapter.MessageAdapterListener { private List<Message> messages = new ArrayList<>(); private RecyclerView recyclerView; private MessagesAdapter mAdapter; private SwipeRefreshLayout swipeRefreshLayout; private ActionModeCallback actionModeCallback; private ActionMode actionMode; @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.activity_main); Toolbar toolbar = (Toolbar) findViewById(R.id.toolbar); setSupportActionBar(toolbar); FloatingActionButton fab = (FloatingActionButton) findViewById(R.id.fab); fab.setOnClickListener(new View.OnClickListener() { @Override public void onClick(View view) { Snackbar.make(view, "Replace with your own action", Snackbar.LENGTH_LONG) .setAction("Action", null).show(); } }); recyclerView = (RecyclerView) findViewById(R.id.recycler_view); swipeRefreshLayout = (SwipeRefreshLayout) findViewById(R.id.swipe_refresh_layout); swipeRefreshLayout.setOnRefreshListener(this); mAdapter = new MessagesAdapter(this, messages, this); RecyclerView.LayoutManager mLayoutManager = new LinearLayoutManager(getApplicationContext()); recyclerView.setLayoutManager(mLayoutManager); recyclerView.setItemAnimator(new DefaultItemAnimator()); recyclerView.addItemDecoration(new DividerItemDecoration(this, LinearLayoutManager.VERTICAL)); recyclerView.setAdapter(mAdapter); actionModeCallback = new ActionModeCallback(); // show loader and fetch messages swipeRefreshLayout.post( new Runnable() { @Override public void run() { getInbox(); } } ); } /** * Fetches mail messages by making HTTP request * url: http://api.androidhive.info/json/inbox.json */ private void getInbox() { swipeRefreshLayout.setRefreshing(true); ApiInterface apiService = ApiClient.getClient().create(ApiInterface.class); Call<List<Message>> call = apiService.getInbox(); call.enqueue(new Callback<List<Message>>() { @Override public void onResponse(Call<List<Message>> call, Response<List<Message>> response) { // clear the inbox messages.clear(); // add all the messages // messages.addAll(response.body()); // TODO - avoid looping // the loop was performed to add colors to each message for (Message message : response.body()) { // generate a random color message.setColor(getRandomMaterialColor("400")); messages.add(message); } mAdapter.notifyDataSetChanged(); swipeRefreshLayout.setRefreshing(false); } @Override public void onFailure(Call<List<Message>> call, Throwable t) { Toast.makeText(getApplicationContext(), "Unable to fetch json: " + t.getMessage(), Toast.LENGTH_LONG).show(); swipeRefreshLayout.setRefreshing(false); } }); } /** * chooses a random color from array.xml */ private int getRandomMaterialColor(String typeColor) { int returnColor = Color.GRAY; int arrayId = getResources().getIdentifier("mdcolor_" + typeColor, "array", getPackageName()); if (arrayId != 0) { TypedArray colors = getResources().obtainTypedArray(arrayId); int index = (int) (Math.random() * colors.length()); returnColor = colors.getColor(index, Color.GRAY); colors.recycle(); } return returnColor; } @Override public boolean onCreateOptionsMenu(Menu menu) { // Inflate the menu; this adds items to the action bar if it is present. getMenuInflater().inflate(R.menu.menu_main, menu); return true; } @Override public boolean onOptionsItemSelected(MenuItem item) { // Handle action bar item clicks here. The action bar will // automatically handle clicks on the Home/Up button, so long // as you specify a parent activity in AndroidManifest.xml. int id = item.getItemId(); //noinspection SimplifiableIfStatement if (id == R.id.action_search) { Toast.makeText(getApplicationContext(), "Search...", Toast.LENGTH_SHORT).show(); return true; } return super.onOptionsItemSelected(item); } @Override public void onRefresh() { // swipe refresh is performed, fetch the messages again getInbox(); } @Override public void onIconClicked(int position) { if (actionMode == null) { actionMode = startSupportActionMode(actionModeCallback); } toggleSelection(position); } @Override public void onIconImportantClicked(int position) { // Star icon is clicked, // mark the message as important Message message = messages.get(position); message.setImportant(!message.isImportant()); messages.set(position, message); mAdapter.notifyDataSetChanged(); } @Override public void onMessageRowClicked(int position) { // verify whether action mode is enabled or not // if enabled, change the row state to activated if (mAdapter.getSelectedItemCount() > 0) { enableActionMode(position); } else { // read the message which removes bold from the row Message message = messages.get(position); message.setRead(true); messages.set(position, message); mAdapter.notifyDataSetChanged(); Toast.makeText(getApplicationContext(), "Read: " + message.getMessage(), Toast.LENGTH_SHORT).show(); } } @Override public void onRowLongClicked(int position) { // long press is performed, enable action mode enableActionMode(position); } private void enableActionMode(int position) { if (actionMode == null) { actionMode = startSupportActionMode(actionModeCallback); } toggleSelection(position); } private void toggleSelection(int position) { mAdapter.toggleSelection(position); int count = mAdapter.getSelectedItemCount(); if (count == 0) { actionMode.finish(); } else { actionMode.setTitle(String.valueOf(count)); actionMode.invalidate(); } } private class ActionModeCallback implements ActionMode.Callback { @Override public boolean onCreateActionMode(ActionMode mode, Menu menu) { mode.getMenuInflater().inflate(R.menu.menu_action_mode, menu); // disable swipe refresh if action mode is enabled swipeRefreshLayout.setEnabled(false); return true; } @Override public boolean onPrepareActionMode(ActionMode mode, Menu menu) { return false; } @Override public boolean onActionItemClicked(ActionMode mode, MenuItem item) { switch (item.getItemId()) { case R.id.action_delete: // delete all the selected messages deleteMessages(); mode.finish(); return true; default: return false; } } @Override public void onDestroyActionMode(ActionMode mode) { mAdapter.clearSelections(); swipeRefreshLayout.setEnabled(true); actionMode = null; recyclerView.post(new Runnable() { @Override public void run() { mAdapter.resetAnimationIndex(); // mAdapter.notifyDataSetChanged(); } }); } } // deleting the messages from recycler view private void deleteMessages() { mAdapter.resetAnimationIndex(); List<Integer> selectedItemPositions = mAdapter.getSelectedItems(); for (int i = selectedItemPositions.size() - 1; i >= 0; i--) { mAdapter.removeData(selectedItemPositions.get(i)); } mAdapter.notifyDataSetChanged(); } }
运行项目就可以看到实际的效果了,确保设备的网络状况良好。