JD Hrnnts JD Hrnnts - 1 month ago 6
Android Question

How to make an Android custom view that is compatible with RecyclerView

I have created a custom view that only extends the View class. The custom view works perfectly, except when being used inside a RecyclerView. This is the custom view:

public class KdaBar extends View {
private int mKillCount, mDeathCount, mAssistCount;
private int mKillColor, mDeathColor, mAssistColor;
private int mViewWidth, mViewHeight;
private Paint mKillBarPaint, mDeathBarPaint, mAssistBarPaint, mBgPaint;
private float mKillPart, mDeathPart, mAssistPart;

public KdaBar(Context context, AttributeSet attrs) {
super(context, attrs);

TypedArray a = context.getTheme().obtainStyledAttributes(
attrs,
R.styleable.KdaBar,
0, 0);

try {
mKillCount = a.getInt(R.styleable.KdaBar_killCount, 0);
mDeathCount = a.getInt(R.styleable.KdaBar_deathCount, 0);
mAssistCount = a.getInt(R.styleable.KdaBar_assistCount, 0);

mKillColor = a.getColor(R.styleable.KdaBar_killBarColor, ContextCompat.getColor(getContext(), R.color.kill_score_color));
mDeathColor = a.getColor(R.styleable.KdaBar_deathBarColor, ContextCompat.getColor(getContext(), R.color.death_score_color));
mAssistColor = a.getColor(R.styleable.KdaBar_assistBarColor, ContextCompat.getColor(getContext(), R.color.assist_score_color));
} finally {
a.recycle();
}

init();
}

public void setValues(int killCount, int deathCount, int assistCount) {

mKillCount = killCount;
mDeathCount = deathCount;
mAssistCount = assistCount;

invalidate();
}

@Override
public void onDraw(Canvas canvas) {
super.onDraw(canvas);

canvas.drawRect(0f, 0f, mViewWidth, mViewHeight, mBgPaint);
canvas.drawRect(mKillPart+mDeathPart, 0f, mKillPart+mDeathPart+mAssistPart, mViewHeight, mAssistBarPaint);
canvas.drawRect(mKillPart, 0f, mKillPart+mDeathPart, mViewHeight, mDeathBarPaint);
canvas.drawRect(0f, 0f, mKillPart, mViewHeight, mKillBarPaint);
}

@Override
protected void onSizeChanged(int xNew, int yNew, int xOld, int yOld){
super.onSizeChanged(xNew, yNew, xOld, yOld);

mViewWidth = xNew;
mViewHeight = yNew;

float total = mKillCount + mDeathCount + mAssistCount;
mKillPart = (mKillCount/total) * mViewWidth;
mDeathPart = (mDeathCount/total) * mViewWidth;
mAssistPart = (mAssistCount/total) * mViewWidth;
}

private void init() {
mKillBarPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
mKillBarPaint.setColor(mKillColor);

mDeathBarPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
mDeathBarPaint.setColor(mDeathColor);

mAssistBarPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
mAssistBarPaint.setColor(mAssistColor);

mBgPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
mBgPaint.setColor(ContextCompat.getColor(getContext(), R.color.transparent));
}
}


The linked image is what the custom view currently looks like (The custom view is the rectangle above the numbers at the center) http://imgur.com/a/Ib5Yl

The numbers below that bar represents their value (They are color-coded in case you haven't noticed). It is obvious that a value of zero on the first item shouldn't show a blue bar on the custom view. Weird, I know.

The method below is where the values are set (it is inside the RecyclerView.Adapter<>):

@Override
public void onBindViewHolder(ViewHolder holder, int position) {
MatchHistory.Match item = mDataset.get(position);
MatchHistory.MatchPlayer[] players = item.getPlayers();

for(MatchHistory.MatchPlayer player: players) {
int steamId32 = (int) Long.parseLong(mCurrentPlayer.getSteamId());
if (steamId32 == player.getAccountId()) {
mCurrentMatchPlayer = player;
}
}
...
holder.mKdaBar.setValues(mCurrentMatchPlayer.getKills(), mCurrentMatchPlayer.getDeaths(), mCurrentMatchPlayer.getAssists());
...
}


This is the onCreateViewHolder:

@Override
public MatchesAdapter.ViewHolder onCreateViewHolder(ViewGroup parent, int viewType) {
View v = LayoutInflater.from(parent.getContext()).inflate(R.layout.fragment_match_item, parent, false);
ViewHolder vh = new ViewHolder(v);
return vh;
}


and the ViewHolder class:

public static class ViewHolder extends RecyclerView.ViewHolder {
KdaBar mKdaBar;

public ViewHolder(View v) {
super(v);
...
mKdaBar = (KdaBar) v.findViewById(R.id.kda_bar);
...
}
}


I think it is useful to note that the dataset being used by the adapter changes the position of the items from time to time (since it is being fetched all at the same time but are inserted so that the dataset is ordered). I almost forgot that I also tested not changing the positions of the items inside the dataset, but still there aren't any good results. If you checked the image, you can see that there are other info inside the items and I am 100% sure those are all correct with the exception of the data in the custom view.

I am thinking that I am forgetting some methods that must be overridden but I already saw a lot of tutorials and none of them mentioned about this issue. Looking forward to solving this issue. TIA!

Answer Source

The problem is not with the dataset but with my understanding of how RecyclerView works underneath (just as napkinsterror have mentioned in his answer).

This it the revised custom view:

public class KdaBar extends View {
    private int mKillCount, mDeathCount, mAssistCount;
    private int mKillColor, mDeathColor, mAssistColor;
    private int mViewWidth, mViewHeight;
    private Paint mKillBarPaint, mDeathBarPaint, mAssistBarPaint, mBgPaint;
    private float mKillPart, mDeathPart, mAssistPart;

    public KdaBar(Context context, AttributeSet attrs) {
        super(context, attrs);

        TypedArray a = context.getTheme().obtainStyledAttributes(
                attrs,
                R.styleable.KdaBar,
                0, 0);

        try {
            mKillCount = a.getInt(R.styleable.KdaBar_killCount, 0);
            mDeathCount = a.getInt(R.styleable.KdaBar_deathCount, 0);
            mAssistCount = a.getInt(R.styleable.KdaBar_assistCount, 0);

            mKillColor = a.getColor(R.styleable.KdaBar_killBarColor, ContextCompat.getColor(getContext(), R.color.kill_score_color));
            mDeathColor = a.getColor(R.styleable.KdaBar_deathBarColor, ContextCompat.getColor(getContext(), R.color.death_score_color));
            mAssistColor = a.getColor(R.styleable.KdaBar_assistBarColor, ContextCompat.getColor(getContext(), R.color.assist_score_color));
        } finally {
            a.recycle();
        }

        init();
    }

    public void setValues(int killCount, int deathCount, int assistCount) {
        mKillCount = killCount;
        mDeathCount = deathCount;
        mAssistCount = assistCount;
    }

    private void calculatePartitions() {
        float total = mKillCount + mDeathCount + mAssistCount;
        mKillPart = (mKillCount/total) * mViewWidth;
        mDeathPart = (mDeathCount/total) * mViewWidth;
        mAssistPart = (mAssistCount/total) * mViewWidth;
    }

    @Override
    public void onDraw(Canvas canvas) {
        super.onDraw(canvas);

        calculatePartitions();

        canvas.drawRect(mKillPart+mDeathPart, 0f, mKillPart+mDeathPart+mAssistPart, mViewHeight, mAssistBarPaint);
        canvas.drawRect(mKillPart, 0f, mKillPart+mDeathPart, mViewHeight, mDeathBarPaint);
        canvas.drawRect(0f, 0f, mKillPart, mViewHeight, mKillBarPaint);
    }

    @Override
    protected void onSizeChanged(int xNew, int yNew, int xOld, int yOld){
        super.onSizeChanged(xNew, yNew, xOld, yOld);

        mViewWidth = xNew;
        mViewHeight = yNew;
    }

    private void init() {
        mKillBarPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
        mKillBarPaint.setColor(mKillColor);

        mDeathBarPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
        mDeathBarPaint.setColor(mDeathColor);

        mAssistBarPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
        mAssistBarPaint.setColor(mAssistColor);

        mBgPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
        mBgPaint.setColor(ContextCompat.getColor(getContext(), R.color.transparent));
    }
}

These are the changes I made:

  1. Removed the invalidate() call from inside the setValues() since the onDraw() callback is invoked when the parent adds a view.
  2. Moved the assignment of mKillPart, mDeathPart, and mAssistPart to calculatePartitions() which is, in turn, called inside onDraw(). This is because the values needed for the calculation are asssured to be complete inside onDraw(). This will be explained below.

This is what I've gathered from Mr. napkinsterror's answer:

When the LayoutManager asks the RecyclerView for a view, ultimately, the onBindViewHolder() method is called. Within that method, data is bound to the views, thus setValues() is called.

The view is returned to the LayoutManager, which will then add the item back to the RecyclerView. This event will trigger onSizeChanged() because the dimensions of the view are not known yet. That's where the mViewWidth and mViewHeight are retrieved. At this point, all the necessary values for calculatePartitions() are complete.

onDraw() is also called because the parent just added an item (check this image). calculatePartitions() is called inside onDraw() and the view will be drawn on the canvas without any problem.

The reason I get wrong values before is because I do the calculatePartitions() inside onSizeChanged() which is very, very wrong.

I will mark this as the answer but many thanks to mr. napkinsterror for providing resources so that I can research in the right direction. :)