In daily Android development, comment and reply functions are one of the requirements we often encounter, and the display of comment and reply lists generally accounts for a large proportion of the functional modules. For companies with frequent requirement changes and iterations, how to quickly develop a secondary interface to adapt to our functional requirements is undoubtedly a higher priority. First, let's take a look at how the comment and reply lists of other social apps are displayed: Twitter is a world-renowned social platform with hundreds of millions of users. Their comments and replies only display the first level of data (comment data). For other content (reply content), you need to jump to the page to view it. Zhihu is similar. The first picture was found by our designer. He said that we should follow this style and try to display the comments and replies on one page. Well, there is no way. After all, we are front-end developers. The UI depends on the designer's mood, and the data depends on the background's mood. When we see the design, we must think of the solution first: use recyclerview? listview? No, after analyzing its hierarchy, we find that the comments are a list, and the replies in it are another list. Is it possible to use recyclerview or listview nesting? With an uncertain attitude, I immediately went online to check. Sure enough, most of the implementation methods I found were implemented by nesting. Before coming to the company, the comment reply function in one of the projects used nested listview. Although it handled the sliding conflict problem, the effect was not good and it often jammed. So, I must change my mind here. There are also people online who say that they use custom views to implement it, but I found that most of them do not deal with view reuse, and the development cost is high, so I will not consider it for the time being. What should I do? I accidentally saw the keyword expandable, and it suddenly occurred to me that Google had released an expandable list control a long time ago - ExpandableListView, but I heard that it is relatively old and has some problems. Forget it, I will give it a try and get familiar with the usage of the previous basic controls. Let’s take a look at the final effect picture first: This is just a simple rendering, you can improve it based on this. Okay, without further ado, let's take a look at how the effect is implemented. It should not be difficult to see that the entire page uses CoordinatorLayout to achieve the top parallax effect of the details page. At the same time, here I use ExpandableListView to implement multi-level lists, and then solve their nested sliding problems. OK, let's start with ExpandableListView. ExpandableListView The official explanation for ExpandableListView is as follows: A view that shows items in a vertically scrolling two-level list. This differs from the ListView by allowing two levels: groups which can individually be expanded to show its children. The items come from the ExpandableListAdapter associated with this view. Simply put, ExpandableListView is a secondary list view for vertical scrolling. The difference between ExpandableListView and ListView is that it can implement secondary grouping and bind data and views through ExpandableListAdapter. Let's implement the effect shown above. Defined in layout First, we need to declare the ExpandableListView in the xml layout file: - <ExpandableListView
- android:id= "@+id/detail_page_lv_comment"
- android:layout_width= "match_parent"
- android:layout_height= "match_parent"
- android:divider= "@null"
- android:layout_marginBottom= "64dp"
- android:listSelector= "@android:color/transparent"
- android:scrollbars= "none" />
There are two issues to be explained here: - ExpandableListView adds a click effect to its items by default. Since the item also contains childItem, after clicking, the entire item will have a click effect. We can cancel the click effect to avoid affecting the user experience. We only need to set the listSelector in the above code.
- ExpandableListView has a default divider, which can be hidden via the divider property.
Setting up the Adapter Just like using listView, we need to set an adapter for ExpandableListView to bind data and view to it. The adapter of ExpandableListView needs to inherit from ExpandableListAdapter. The specific code is as follows: - public class CommentExpandAdapter extends BaseExpandableListAdapter {
- private static final String TAG = "CommentExpandAdapter" ;
- private List<CommentDetailBean> commentBeanList;
- private Context context;
- public CommentExpandAdapter(Context context, List<CommentDetailBean> commentBeanList) {
- this.context = context;
- this.commentBeanList = commentBeanList;
- }
- @Override
- public int getGroupCount() {
- return commentBeanList. size ();
- }
- @Override
- public int getChildrenCount( int i) {
- if(commentBeanList.get(i).getReplyList() == null ){
- return 0;
- } else {
- return commentBeanList.get(i).getReplyList(). size ()>0 ? commentBeanList.get(i).getReplyList(). size ():0;
- }
- }
- @Override
- public Object getGroup( int i) {
- return commentBeanList.get(i);
- }
- @Override
- public Object getChild( int i, int i1) {
- return commentBeanList.get(i).getReplyList().get(i1);
- }
- @Override
- public long getGroupId( int groupPosition) {
- return groupPosition;
- }
- @Override
- public long getChildId( int groupPosition, int childPosition) {
- return getCombinedChildId(groupPosition, childPosition);
- }
- @Override
- public boolean hasStableIds() {
- return true ;
- }
- boolean isLike = false ;
- @Override
- public View getGroupView(final int groupPosition, boolean isExpand, View convertView, ViewGroup viewGroup) {
- final GroupHolder groupHolder;
- if(convertView == null ){
- convertView = LayoutInflater. from (context).inflate(R.layout.comment_item_layout, viewGroup, false );
- groupHolder = new GroupHolder(convertView);
- convertView.setTag(groupHolder);
- } else {
- groupHolder = (GroupHolder) convertView.getTag();
- }
- Glide. with (context). load (R.drawable.user_other)
- .diskCacheStrategy(DiskCacheStrategy.RESULT)
- .error(R.mipmap.ic_launcher)
- .centerCrop()
- . into (groupHolder.logo);
- groupHolder.tv_name.setText(commentBeanList.get(groupPosition).getNickName());
- groupHolder.tv_time.setText(commentBeanList.get(groupPosition).getCreateDate());
- groupHolder.tv_content.setText(commentBeanList.get(groupPosition).getContent());
- groupHolder.iv_like.setOnClickListener(new View .OnClickListener() {
- @Override
- public void onClick( View view ) {
- if(isLike){
- isLike = false ;
- groupHolder.iv_like.setColorFilter(Color.parseColor( "#aaaaaa" ));
- } else {
- isLike = true ;
- groupHolder.iv_like.setColorFilter(Color.parseColor( "#FF5C5C" ));
- }
- }
- });
- return convertView;
- }
- @Override
- public View getChildView(final int groupPosition, int childPosition, boolean b, View convertView, ViewGroup viewGroup) {
- final ChildHolder childHolder;
- if(convertView == null ){
- convertView = LayoutInflater. from (context).inflate(R.layout.comment_reply_item_layout,viewGroup, false );
- childHolder = new ChildHolder(convertView);
- convertView.setTag(childHolder);
- }
- else {
- childHolder = (ChildHolder) convertView.getTag();
- }
- String replyUser = commentBeanList.get(groupPosition).getReplyList().get(childPosition).getNickName();
- if(!TextUtils.isEmpty(replyUser)){
- childHolder.tv_name.setText(replyUser + ":" );
- }
- childHolder.tv_content.setText(commentBeanList.get(groupPosition).getReplyList().get(childPosition).getContent());
- return convertView;
- }
- @Override
- public boolean isChildSelectable( int i, int i1) {
- return true ;
- }
- private class GroupHolder{
- private CircleImageView logo;
- private TextView tv_name, tv_content, tv_time;
- private ImageView iv_like;
- public GroupHolder( View view ) {
- logo = view .findViewById(R.id.comment_item_logo);
- tv_content = view .findViewById(R.id.comment_item_content);
- tv_name = view .findViewById(R.id.comment_item_userName);
- tv_time = view .findViewById(R.id.comment_item_time);
- iv_like = view .findViewById(R.id.comment_item_like);
- }
- }
- private class ChildHolder{
- private TextView tv_name, tv_content;
- public ChildHolder( View view ) {
- tv_name = (TextView) view .findViewById(R.id.reply_item_user);
- tv_content = (TextView) view .findViewById(R.id.reply_item_content);
- }
- }
- }
Generally speaking, after we customize our own ExpandableListAdapter, we need to implement the following methods: - The construction method, needless to say, is generally used to initialize data and other operations.
- getGroupCount, returns the number of group groups, which refers to the number of comments in the current requirement.
- getChildrenCount, returns the number of children in the group, which here refers to the number of replies corresponding to the current comment.
- getGroup, returns the actual data of the group, here refers to the current comment data.
- getChild, returns the actual data of a child in the group, which refers to the reply data of the current comment.
- getGroupId, returns the group id, usually passing the current group's position to it.
- getChildId returns the id of a child in the group. Generally, the current position of the child is also passed to it. However, in order to avoid duplication, you can use getCombinedChildId(groupPosition, childPosition); to get the id and return it.
- hasStableIds, indicating whether the group and sub-options have stable ids, just return true here.
- isChildSelectable, indicates whether the child in the group can be selected, and returns true here.
- getGroupView, that is, returns the view of the group. Generally, some data and view binding work is performed here. Generally, for reuse and efficiency, you can customize ViewHolder. The usage is the same as listview, so I won’t say more here.
- getChildView returns the view of the child item in the group. It is relatively easy to understand. The first parameter is the position of the current group, and the second parameter is the position of the current child.
The data here is simulated data made by myself, but it should be considered a more common format. The general format is as follows: Generally, our backend will return some data to us through the interface. If you want to view more comments, you need to jump to the "more pages" to view them. Here, for convenience, we only consider loading part of the data. Use in Activity Next, we need to display the secondary list of comments and replies in the activity: - private ExpandableListView expandableListView;
- private CommentExpandAdapter adapter;
- private CommentBean commentBean;
- private List<CommentDetailBean> commentsList;
- ...
- @Override
- protected void onCreate(Bundle savedInstanceState) {
- super.onCreate(savedInstanceState);
- setContentView(R.layout.activity_main);
- initView();
- }
- private void initView() {
- expandableListView = findViewById(R.id.detail_page_lv_comment);
- initExpandableListView(commentsList);
- }
- /**
- * Initialize the comment and reply list
- */
- private void initExpandableListView(final List<CommentDetailBean> commentList){
- expandableListView.setGroupIndicator( null );
- //Expand all replies by default
- adapter = new CommentExpandAdapter(this, commentList);
- expandableListView.setAdapter(adapter);
- for ( int i = 0; i<commentList. size (); i++){
- expandableListView.expandGroup(i);
- }
- expandableListView.setOnGroupClickListener(new ExpandableListView.OnGroupClickListener() {
- @Override
- public boolean onGroupClick(ExpandableListView expandableListView, View view , int groupPosition, long l) {
- boolean isExpanded = expandableListView.isGroupExpanded(groupPosition);
- Log.e(TAG, "onGroupClick: current comment id>>>" + commentList.get(groupPosition).getId());
- // if(isExpanded){
- // expandableListView.collapseGroup(groupPosition);
- // } else {
- // expandableListView.expandGroup(groupPosition, true );
- // }
- return true ;
- }
- });
- expandableListView.setOnChildClickListener(new ExpandableListView.OnChildClickListener() {
- @Override
- public boolean onChildClick(ExpandableListView expandableListView, View view , int groupPosition, int childPosition, long l) {
- Toast.makeText(MainActivity.this, "Clicked reply" , Toast.LENGTH_SHORT).show();
- return false ;
- }
- });
- expandableListView.setOnGroupExpandListener(new ExpandableListView.OnGroupExpandListener() {
- @Override
- public void onGroupExpand( int groupPosition) {
- //toast( "Expand the" +groupPosition+ "group" );
- }
- });
- }
- /**
- * func: Generate test data
- * @return comment data
- */
- private List<CommentDetailBean> generateTestData(){
- Gson gson = new Gson();
- commentBean = gson.fromJson(testJson, CommentBean.class);
- List<CommentDetailBean> commentList = commentBean.getData().getList();
- return commentList;
- }
Here is a brief explanation of the above code: By default, ExpandableListView provides us with its own grouping icon(). Under current requirements, we don’t need to display it at all. We can hide it by calling expandableListView.setGroupIndicator(null). In general, we may need to expand all groups by default, so I can call the expandableListView.expandGroup(i); method through a loop. ExpandableListView provides us with group and child click events, which can be set by setOnGroupClickListener and setOnChildClickListener respectively. It is worth noting that if we return false in the group click event, the group will be automatically expanded when we click it, but I encountered a problem here. When I return false, there will be an extra comment in the first comment data. Although there are many similar problems found by searching Baidu, they are not solved after all. Finally, I returned true and manually expanded and contracted them by the following code: - if(isExpanded){
- expandableListView.collapseGroup(groupPosition);
- } else {
- expandableListView.expandGroup(groupPosition, true );
- }
In addition, we can also use setOnGroupExpandListener and setOnGroupCollapseListener to monitor the group expansion and contraction status of ExpandableListView. Comment and reply functions In order to simulate the entire comment and reply function, we also need to manually insert the receipt and refresh the data list. Here I will simply do a simulation, please ignore some UI details. Insert comment data Inserting comment data is relatively simple. You only need to insert a piece of data into the list and refresh it: - String commentContent = commentText.getText().toString().trim();
- if(!TextUtils.isEmpty(commentContent)){
- //commentOnWork(commentContent);
- dialog.dismiss();
- CommentDetailBean detailBean = new CommentDetailBean( "Xiao Ming" , commentContent, "Just now" );
- adapter.addTheCommentData(detailBean);
- Toast.makeText(MainActivity.this, "Comment successful" , Toast.LENGTH_SHORT).show();
- } else {
- Toast.makeText(MainActivity.this, "Comment content cannot be empty" , Toast.LENGTH_SHORT).show();
- }
The addTheCommentData method in the adapter is as follows: - public void addTheCommentData(CommentDetailBean commentDetailBean){
- if(commentDetailBean!= null ){
- commentBeanList.add ( commentDetailBean );
- notifyDataSetChanged();
- } else {
- throw new IllegalArgumentException( "Comment data is empty!" );
- }
- }
The code is relatively easy to understand, so I won't explain it in detail. Insert reply data First, we need to click on a comment and then @ta, so we need to pop up the reply box in the group's click event: - expandableListView.setOnGroupClickListener(new ExpandableListView.OnGroupClickListener() {
- @Override
- public boolean onGroupClick(
- ExpandableListView expandableListView, View view , int groupPosition, long l) {
- showReplyDialog(groupPosition);
- return true ;
- }
- });
- ......
- /**
- * func: Pop-up reply box
- */
- private void showReplyDialog(final int position){
- dialog = new BottomSheetDialog(this);
- View commentView = LayoutInflater. from (this).inflate(R.layout.comment_dialog_layout, null );
- final EditText commentText = (EditText) commentView.findViewById(R.id.dialog_comment_et);
- final Button bt_comment = (Button) commentView.findViewById(R.id.dialog_comment_bt);
- commentText.setHint( "Reply to " + commentsList.get(position).getNickName() + "'s comment:" );
- dialog.setContentView(commentView);
- bt_comment.setOnClickListener(new View .OnClickListener() {
- @Override
- public void onClick( View view ) {
- String replyContent = commentText.getText().toString().trim();
- if(!TextUtils.isEmpty(replyContent)){
- dialog.dismiss();
- ReplyDetailBean detailBean = new ReplyDetailBean( "小红" ,replyContent);
- adapter.addTheReplyData(detailBean, position);
- Toast.makeText(MainActivity.this, "Reply Successfully" , Toast.LENGTH_SHORT).show();
- } else {
- Toast.makeText(MainActivity.this, "The reply content cannot be empty" , Toast.LENGTH_SHORT).show();
- }
- }
- });
- commentText.addTextChangedListener(new TextWatcher() {
- @Override
- public void beforeTextChanged(CharSequence charSequence, int i, int i1, int i2) {
- }
- @Override
- public void onTextChanged(CharSequence charSequence, int i, int i1, int i2) {
- if(!TextUtils.isEmpty(charSequence) && charSequence.length()>2){
- bt_comment.setBackgroundColor(Color.parseColor( "#FFB568" ));
- } else {
- bt_comment.setBackgroundColor(Color.parseColor( "#D8D8D8" ));
- }
- }
- @Override
- public void afterTextChanged(Editable editable) {
- }
- });
- dialog.show();
- }
Inserting reply data is similar to inserting comments above. Here is the code in the adapter: - /**
- * by moos on 2018/04/20
- * func: insert a data after replying successfully
- * @param replyDetailBean new reply data
- */
- public void addTheReplyData(ReplyDetailBean replyDetailBean, int groupPosition){
- if(replyDetailBean!= null ){
- Log.e(TAG, "addTheReplyData: >>>>It's time to refresh the reply list:" + replyDetailBean.toString() );
- if(commentBeanList.get(groupPosition).getReplyList() != null ){
- commentBeanList.get(groupPosition).getReplyList(). add (replyDetailBean);
- } else {
- List<ReplyDetailBean> replyList = new ArrayList<>();
- replyList.add (replyDetailBean);
- commentBeanList.get(groupPosition).setReplyList(replyList);
- }
- notifyDataSetChanged();
- } else {
- throw new IllegalArgumentException( "The reply data is empty!" );
- }
- }
One thing to note is that not all comments have reply data, so before inserting data we need to determine whether ReplyList is empty. If it is not empty, directly get the reply list of the current comment and insert the data; if it is empty, you need to create a new ReplyList, and after inserting the data, you also need to set ReplyList for the comment. 4. Solve the nesting problem of CoordinatorLayout and ExpandableListView If you don't need to use CoordinatorLayout or NestedScrollView, you can skip this section. Generally speaking, for a better user experience, our products also require us to add similar top parallax effects or pull-down refresh, etc., which requires us to deal with some common nested sliding problems. Since CoordinatorLayout implements the NestedScrollingParent interface and RecycleView implements the NestedScrollingChild interface, nested sliding can be achieved with the help of NestedScrollingChildHelper. Then we can also achieve the same effect by implementing the NestedScrollingChild interface through a custom ExpandableListView: - /**
- * Desc : Customize ExpandableListView to solve the sliding conflict with CoordinatorLayout
- */
- public class CommentExpandableListView extends ExpandableListView implements NestedScrollingChild{
- private NestedScrollingChildHelper mScrollingChildHelper;
- public CommentExpandableListView(Context context, AttributeSet attrs) {
- super(context, attrs);
- mScrollingChildHelper = new NestedScrollingChildHelper(this);
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
- setNestedScrollingEnabled( true );
- }
- }
- @Override
- protected void onMeasure( int widthMeasureSpec, int heightMeasureSpec) {
- int expandSpec = MeasureSpec.makeMeasureSpec( Integer .MAX_VALUE >> 2, MeasureSpec.AT_MOST);
- super.onMeasure(widthMeasureSpec, expandSpec);
- }
- @Override
- public void setNestedScrollingEnabled(boolean enabled) {
- mScrollingChildHelper.setNestedScrollingEnabled(enabled);
- }
- @Override
- public boolean isNestedScrollingEnabled() {
- return mScrollingChildHelper.isNestedScrollingEnabled();
- }
- @Override
- public boolean startNestedScroll( int axes) {
- return mScrollingChildHelper.startNestedScroll(axes);
- }
- @Override
- public void stopNestedScroll() {
- mScrollingChildHelper.stopNestedScroll();
- }
- @Override
- public boolean hasNestedScrollingParent() {
- return mScrollingChildHelper.hasNestedScrollingParent();
- }
- @Override
- public boolean dispatchNestedScroll( int dxConsumed, int dyConsumed, int dxUnconsumed,
- int dyUnconsumed, int [] offsetInWindow) {
- return mScrollingChildHelper.dispatchNestedScroll(dxConsumed, dyConsumed,
- dxUnconsumed, dyUnconsumed, offsetInWindow);
- }
- @Override
- public boolean dispatchNestedPreScroll( int dx, int dy, int [] consumed, int [] offsetInWindow) {
- return mScrollingChildHelper.dispatchNestedPreScroll(dx, dy, consumed, offsetInWindow);
- }
- @Override
- public boolean dispatchNestedFling( float velocityX, float velocityY, boolean consumed) {
- return mScrollingChildHelper.dispatchNestedFling(velocityX, velocityY, consumed);
- }
- @Override
- public boolean dispatchNestedPreFling( float velocityX, float velocityY) {
- return mScrollingChildHelper.dispatchNestedPreFling(velocityX, velocityY);
- }
- }
I won’t explain the code, after all, this is not the focus of this article. You can go online to check out NestedScrollView related articles or source code to compare and understand. The complete layout code is quite long and the space is limited. You can check it on github: https://github.com/Moosphan/CommentWithReplyView-master |