仿網易新聞評論“蓋樓”效果實現

前言

各位應該對黃易新聞比較熟悉,其中評論區一般都會出現一些蓋樓的神評論,今天的主題就是仿照做一個有蓋樓效果的評論列表。

首先上圖給大家看下效果:

實現效果

思路

數據結構設計

首先分析看下評論的結構,仔細觀摩下發現,有的評論是簡單一條,只有用戶頭像,暱稱,評論內容等;有的評論是回覆別人的評論,這樣就不只是有用戶頭像,評論內容等基礎信息,還有回覆的別人的內容,這就是所謂“蓋樓”了。那麼怎麼設計評論的model可以包含評論的所有數據呢?

首先滿足簡單評論的model很容易,如下:

public class Comment {
    private int id;
    private String mReplyTime;
    private String mAvaterUrl;
    private String mUsername;
    private String mUserArea;
    private String mCommentContent;
    private int mFavorCount;

    //getter setter
    ...
    }

以上包括了一條簡單評論需要展示項的所有信息,可是如果是回覆其他評論的評論,這種存在“蓋樓”的評論項,這種結構就無法滿足了。吃根辣條冷靜一下,再分析蓋樓中的內容,發現蓋樓中的一項內容其實是被回覆的評論,其中包括了用戶暱稱,評論內容。
總結來說,一條評論中可能包括了另一條評論,那麼在Comment中添加一個字段:

private Comment replyTo;

這個字段保存了其回覆的評論,這樣就能形成一個類似單向鏈表的結構了,一條評論項中能夠與它回覆的評論關聯起來,並且能夠依次鏈接,那麼所有回覆的評論內容都能在鏈表中找到,結構清晰明瞭,那麼model就已經設計好了。

界面設計

很明顯這是一個列表結構,最外層咱們可以使用Recyclerview來實現,每個評論項可以使用一個自定義View封裝起來,這個自定義View來處理數據和UI的適配。我們來分析下這個View的構成:
1. 用戶頭像,暱稱,回覆內容等都是基礎內容,每個評論項都會展示出這些信息。
2. 有的回覆其他評論的評論項中間會多出一個評論“樓層”。

這樣簡單一分析,我們就有了思路了,這個自定義的View可以分解成兩個部分,一個部分就是基礎信息展示,另個就是評論“樓層”展示。佈局文件如下:

<?xml version="1.0" encoding="utf-8"?>
<merge xmlns:android="http://schemas.android.com/apk/res/android">

    <RelativeLayout
        android:layout_width="match_parent"
        android:layout_height="wrap_content">

        <io.geek.myapplication.view.CircleView2
            android:id="@+id/iv_avater"
            android:layout_width="50dp"
            android:layout_height="50dp"
            android:layout_alignParentLeft="true"
            android:layout_alignParentStart="true"
            android:layout_alignParentTop="true"/>

        <TextView
            android:id="@+id/tv_username"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:layout_marginTop="5dp"
            android:layout_toRightOf="@+id/iv_avater"
            android:text="Geek"/>

        <TextView
            android:id="@+id/tv_user_area"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:layout_below="@+id/tv_username"
            android:layout_toEndOf="@+id/iv_avater"
            android:layout_toRightOf="@+id/iv_avater"
            android:text="來自火星的網友"/>

        <TextView
            android:id="@+id/tv_reply_time"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:layout_alignBottom="@+id/tv_user_area"
            android:layout_marginLeft="5dp"
            android:layout_marginRight="5dp"
            android:layout_marginTop="5dp"
            android:layout_toRightOf="@+id/tv_user_area"
            android:text="33分鐘前"/>

        <TextView
            android:id="@+id/favor_count"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:layout_alignParentRight="true"
            android:text="2896"
            android:textSize="8dp"/>


    </RelativeLayout>

    <io.geek.myapplication.comment.CommentFloorView
        android:id="@+id/comment_floor"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:visibility="gone"/>

    <TextView
        android:id="@+id/tv_comment_content"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:text="我只是一個普通的評論。"/>
</merge>

tips: 使用merge標籤能夠減少佈局層級,減少界面渲染次數,優化性能。

以上是我們自定義CommentItemView的佈局文件,現在我們來編寫CommentItemView,這個自定義View繼承LinearLayout,然後構造方法中初始化各個需要綁定數據的view,暴露一個綁定數據的bind方法,這樣就能把數據展示在對應的view上了。代碼如下:

public class CommentItemView extends LinearLayout {
    CircleImageView avater;
    TextView tvUsername;
    TextView tvUserArea;
    TextView tvReplyTime;
    TextView tvFavorCount;
    CommentFloorView commentFloorView;
    TextView tvCommentContent;
    Comment mComment;

    public CommentItemView(Context context) {
        this(context, null);
    }

    public CommentItemView(Context context, @Nullable AttributeSet attrs) {
        this(context, attrs, 0);
    }

    public CommentItemView(Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
        this(context, attrs, defStyleAttr, 0);
    }

    public CommentItemView(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) {
        super(context, attrs, defStyleAttr);

        initView(context);
    }

    private void initView(Context context) {
        LayoutInflater.from(context).inflate(R.layout.list_item_view_comment, this,true);
        int padding = getResources().getDimensionPixelOffset(R.dimen.activity_horizontal_margin);
        setLayoutParams(new ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT));
        setOrientation(VERTICAL);
        setPadding(padding, padding, padding, padding);

        avater = (CircleImageView) findViewById(R.id.iv_avater);
        tvUsername = (TextView) findViewById(R.id.tv_username);
        tvUserArea = (TextView) findViewById(R.id.tv_user_area);
        tvReplyTime = (TextView) findViewById(R.id.tv_reply_time);
        commentFloorView = (CommentFloorView) findViewById(R.id.comment_floor);
        tvCommentContent = (TextView) findViewById(R.id.tv_comment_content);
    }

    public void bind(Comment comment) {
        mComment = comment;
        tvUsername.setText(comment.getUsername());
        tvUserArea.setText(comment.getUserArea());
        tvReplyTime.setText(comment.getReplyTime());
        tvCommentContent.setText(comment.getCommentContent());
        commentFloorView = (CommentFloorView) findViewById(R.id.comment_floor);

        setupCommentFloorView();
    }

    private void setupCommentFloorView() {
        List<Comment> replyFloor = getReplyFloor();
        if (replyFloor != null) {
            commentFloorView.setVisibility(VISIBLE);
            commentFloorView.updateData(replyFloor);
        } else {
            commentFloorView.setVisibility(GONE);
        }
    }


    //通過comment中的replyTo字段找到其回覆的評論鏈,按照回覆的順序排列放入list中
    private List<Comment> getReplyFloor( ) {
        List<Comment> floorData = new ArrayList<>();
        if (mComment == null || mComment.getReplyTo() == null) {
            return null;
        }
        Comment reply = mComment.getReplyTo();
        while (reply != null) {
            floorData.add(reply);
            reply = reply.getReplyTo();
        }
        //按照回覆的順序來排列
        Collections.reverse(floorData);
        return floorData;
    }
}

上面的代碼結構很簡單,沒有啥複雜的邏輯,就是一個簡單點的封裝了各個控件的容器,然後有一個綁定數據的方法。可能你看到一個陌生的view:CommentFloorView,以下就是本文需要講的重點!!和難點!!

CommentFloorView也是我們一個自定義view,這個view負責的是評論“樓層”展示的,主體構成是Recylerview,我們來仔細分析下這個評論“樓層”,本質就是一個列表,列表中展示的是評論用戶名和評論的內容,如果這個列表超過了一定的數量就會摺疊展示,點擊展開樓層會全部展示,並且每個項會有邊框,並不是簡單的每個列表項套一個邊框,而是看起來會有層疊效果的邊框。通過這樣一分析,總結出實現這個view的兩個難點:
1. 展開和隱藏樓層。
2. 邊框的繪製。

  • 難點一

這個recylerview中展示的有兩種view,其一就是評論項,其二就是展開/隱藏樓層的按鈕。熟悉recylerview功能應該會知道重寫adapter中的getItemType方法可以實現這種一個recylerview展示不同的類型item。

首先解析展開和隱藏樓層功能,重寫getItemCount方法,代碼如下,解析在註釋:

        @Override
        public int getItemCount() {
            if (hasHideFloor()) {
                if (isFloorExpanded) {
                    //如果有有隱藏樓層,並且樓層是展開的狀態
                    //加的1是因爲最後還有一個隱藏樓層的項
                    return mComments.size() + 1;
                } else {
                    //如果有隱藏樓層,並且沒有展開,那麼只有4項,分別是第一二項數據,展開樓層按鈕和最後一項數據
                    return 4;
                }
            } else {
                //如果沒有隱藏樓層,則返回數據的數量
                return mComments.size();
            }
        }

        //如果數據量大於一個默認值(這裏設定的是5個),那麼默認會有隱藏樓層,小於的話就無需隱藏
        private boolean hasHideFloor() {
            return mComments.size() > EXPAND_LIMIT_COMMENT_COUNT;
        }

然後重現getItemType方法,只要把邏輯理清楚了其實也很簡單,代碼如下:

        @Override
        public int getItemViewType(int position) {
            if (hasHideFloor()) {
                if (isFloorExpanded) {
                    if (position == mComments.size()) {
                        //如果有隱藏樓層,並且樓層是展開狀態,並且是最後一項,那麼返回展開/收起按鈕佈局ID
                        return R.layout.list_item_hide_expand_floor;
                    } else {
                        //如果有隱藏樓層,並且樓層是展開狀態,不是最後一項,返回評論項佈局ID
                        return R.layout.list_item_simple_reply_comment;
                    }
                } else {
                    if (position == EXPAND_FLOOR_ITEM_POSITION) {
                        //如果有隱藏樓層,並且樓層是隱藏狀態,並且是指定的展開樓層按鈕位置,那麼返回展開/收起按鈕佈局ID
                        return R.layout.list_item_hide_expand_floor;
                    } else {
                        //如果有隱藏樓層,並且樓層是隱藏狀態,不是展開樓層按鈕位置,那麼評論項佈局ID
                        return R.layout.list_item_simple_reply_comment;
                    }
                }
            } else {
                //如果沒有隱藏樓層,那麼都是返回評論佈局ID
                return R.layout.list_item_simple_reply_comment;
            }
        }

重寫好了以上兩個方法,就能配合onCreateViewHolder和onBindViewHolder方法來實現列表中不同位置生成不同的View,並且綁定響應的數據項。

這樣就能實現UI效果了,但是有個棘手的問題來了,怎麼做到點擊展開/隱藏樓層後真的能展開和隱藏部分item呢?不要慌,吃根辣條冷靜分析下,我們已經重寫了getItemCount和getItemType方法,判斷邏輯中有利用判定樓層展開和隱藏的字段isFloorExpaned來返回不同的item個數和控制在不同位置返回不同的view,其實實現展開和隱藏樓層的核心部分就寫完了,現在我們只需要控制這個isFloorExpanded的值就行了,具體實現就是編寫一個監聽點擊展開/隱藏樓層按鈕的接口,然後在adapter構造方法中創建一個監聽器,傳入到展開/隱藏樓層按鈕的ViewHolder中,在點擊這個view的時候觸發監聽器,修改isFloorExpanded的值,並且notifyDataSetChanged()方法,重新刷新recylerview。核心代碼如下:

 //這個adapter是編寫在CommentFloorView中的,所以使用了static來修飾
 public static class CommentFloorAdapter extends RecyclerView.Adapter {
        private List<Comment> mComments = new ArrayList<>();

        private OnFloorExpandListener mOnFloorExpandListener;
        private boolean isFloorExpanded;

        public CommentFloorAdapter() {
            mOnFloorExpandListener = new OnFloorExpandListener() {
                @Override
                public void onFloorExpand(boolean isExpand) {
                    isFloorExpanded = isExpand;
                    notifyDataSetChanged();
                }
            };

        }



       //.....

        public interface OnFloorExpandListener {
            void onFloorExpand(boolean isExpand);
        }


        static class HideExpandFloorVH extends RecyclerView.ViewHolder {
            FrameLayout mContentView;
            TextView tips;
            OnFloorExpandListener mOnFloorExpandListener;

            public HideExpandFloorVH(View itemView, OnFloorExpandListener onFloorExpandListener, final boolean isFloorExpanded) {
                super(itemView);
                mOnFloorExpandListener = onFloorExpandListener;
                mContentView = (FrameLayout) itemView;
                tips = (TextView) mContentView.findViewById(R.id.tv_is_show_all);

                mContentView.setOnClickListener(new OnClickListener() {
                    @Override
                    public void onClick(View v) {
                        mOnFloorExpandListener.onFloorExpand(!isFloorExpanded);
                    }
                });
            }

            public void changeTips(boolean isFloorExpanded) {
                tips.setText(isFloorExpanded ? "收起展開樓層" : "展開隱藏樓層");
            }
        }
    }

tips:內部類最好使用static修飾,這樣內部類不會持有外部類的強應用,防止內存泄露,優化性能。

這樣核心的功能就已經實現啦~此時的實現效果如下圖:

實現效果
這樣看着和原版的還是有點差距的,這是沒有繪製邊框的原因,吃根辣條休息下,接下來開始解析難點二了。

  • 難點二

還是先要分析下這個邊框是個啥玩意,看着是有層疊的效果。細細看了下,發現它的規律是第一個繪製一個邊框,然後第一個和第二個作爲整體外層再繪製一個邊框,再前三個作爲一個整體繪製邊框,依次類推。這樣就產生了一種層疊的效果。繪製的規律被我們發現了,那具體如何繪製呢?recyclerview中有一個方法是addItemDecoration,利用ItemDecoration我們可以給每個item繪製邊框或者分割線,這裏我們定義一個CommentFloorItemDecoration類,繼承自ItemDecoration,重寫其中的onDraw和getItemOffsets方法,代碼如下:

public class CommentFloorItemDecoration extends RecyclerView.ItemDecoration {
    private static float BORDER_OFFSET;
    private static float BORDER_WIDTH;
    private Context mContext;
    private Paint mBorderPaint;

    public CommentFloorItemDecoration(Context context) {
        mContext = context;

        BORDER_OFFSET = context.getResources().getDisplayMetrics().density * 1;
        BORDER_WIDTH = context.getResources().getDisplayMetrics().density * 1;
        mBorderPaint = new Paint();
        mBorderPaint.setAntiAlias(true);
        mBorderPaint.setColor(Color.LTGRAY);
        mBorderPaint.setStyle(Paint.Style.STROKE);
        mBorderPaint.setStrokeWidth(BORDER_WIDTH);
    }

    @Override
    public void onDraw(Canvas c, RecyclerView parent, RecyclerView.State state) {
        int itemCount = parent.getChildCount();

        int top = parent.getChildAt(0).getTop();
        for (int i = 0; i < itemCount; i++) {
            View child = parent.getChildAt(i);
            int left = child.getLeft();
            int right = child.getRight();
            int bottom = child.getBottom();
            c.drawRect(left, top, right, bottom, mBorderPaint);
            top -= (BORDER_WIDTH + BORDER_OFFSET);
        }
    }


    @Override
    public void getItemOffsets(Rect outRect, View view, RecyclerView parent, RecyclerView.State state) {
        super.getItemOffsets(outRect, view, parent, state);


        RecyclerView.Adapter adapter = parent.getAdapter();
        int itemCount = adapter.getItemCount();
        int i = parent.getChildAdapterPosition(view);
        int top = (int) ((itemCount - i) * BORDER_WIDTH + (itemCount - i - 1) * BORDER_OFFSET);
        int left = top;
        int right = top;
        int bottom = (int) BORDER_WIDTH;
        if (i == 0) {
            outRect.set(left, top, right, bottom);
        } else {
            outRect.set(left, 0, right, bottom);
        }
    }
}

getItemOffset的作用是得到每個item的偏移量,重寫此方法可以給每個item設置偏移量,在這裏我們按照上面總結出的規律給不同的item設置的不同的offset值,然後再重寫onDraw方法,給item繪製邊框,具體邏輯參看代碼。最後我們就完整實現蓋樓效果了。

全文就此完畢~( 注意: 此文主要是提供一個思路,和原版細節方面有一定出入,並且沒有運用到實際項目中過,需要使用的同學請自行測試)

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章