kanytu / android-parallax-recyclerview

An adapter which could be used to achieve a parallax effect on RecyclerView.
Apache License 2.0
1.61k stars 284 forks source link

List items overlapping header when removing an item #13

Open manujosephv opened 9 years ago

manujosephv commented 9 years ago

Hi There, I have used your Parallax RecyclerView to load data from a cursor. I have modified the adapter to use a cursor instead of the List that is there by default.

I have modified the removeItem method too like below.

public void removeItem(int position) {
    if (position < 0)
        return;
    notifyItemRemoved(position + (mHeader == null ? 0 : 1));

in the loaderFinished method, I swap the cursor and update the header values. in the existing adapter(not create a new adapter and swap adapter at recyclerview level)

This seems to be working perfectly, except for the UI. The values are updating correctly, but the list items seems to be overlapping the header after an item is removed.

This does not happen if the recyclerview is scrolled all the way to the top. But in any other position, it gives me something like this, with the list items overlapping the header (see below)

image

This is the ideal behavior image

Let me know if you want the entire code of the Adapter I am using(the modified one)

kanytu commented 9 years ago

It would be great if you could share the modified adapter. Just the parts you changed, there's no need to share the whole code if you don't want to.

kanytu commented 9 years ago

Okay friend. I've been looking at my code and in fact it's not working with additions and removals of items if the adapter is not on top.

So, I've developed a little "workarround". It's kinda ugly (maybe not that ugly) but I think it will work. First let me ask you. Are your items all the same size? If so, great. Than this will work.

So you will need to create a new field on your adapter. Let's call it:

private int mRowSize = 0;

Next on your onBindViewHolder, and for the ViewType!=0 lets do something like this:

if(mRowSize==0){
    mRowSize= viewHolder.itemView.getHeight();
}

So this will set the row size to the actual row height.

Now whenever you remove an item:

 mTotalYScrolled -=mRowSize;

And when you add an item:

 mTotalYScrolled +=mRowSize;

I think this will work. Not sure. But give it a try and let me know :P

manujosephv commented 9 years ago

I have no problem sharing the whole code.. Not entirely mine either.. I was using an adapter shared by Matthieu Harlé here . https://gist.github.com/Shywim/127f207e7248fe48400b .

I just combined that adapter with yours :D

Right now I am at office. I'll try your fix once I get home.

But to answer your question- Yes, all the list items are of the same size.

Till that time.. here is the adapter I am using. it's a little big. You can ignore the whole subclass for filters. Even I'm not using it. And it's a little dirty, so bear with me.. :D

package com.designs.zoomonkey.writetrack.lib.parallaxrecyclerview;

import android.content.Context;
import android.database.ContentObserver;
import android.database.Cursor;
import android.database.DataSetObserver;
import android.graphics.Canvas;
import android.graphics.Rect;
import android.os.Build;
import android.os.Handler;
import android.support.v7.widget.RecyclerView;
import android.view.View;
import android.view.ViewGroup;
import android.view.animation.TranslateAnimation;
import android.widget.Filter;
import android.widget.FilterQueryProvider;
import android.widget.RelativeLayout;

/**
 * Created by manu.joseph on 07-03-2015.
 * Combined two great libraries for RecyclerViews
 */
public class ParallaxCursorRecyclerAdapter<VH
        extends android.support.v7.widget.RecyclerView.ViewHolder> extends RecyclerView.Adapter<RecyclerView.ViewHolder> implements CursorFilter.CursorFilterClient {

    private final float SCROLL_MULTIPLIER = 0.5f;

    public static class VIEW_TYPES {
        public static final int NORMAL = 1;
        public static final int HEADER = 2;
        public static final int FIRST_VIEW = 3;
    }

    public interface RecyclerAdapterMethods {
        void onBindViewHolder(RecyclerView.ViewHolder viewHolder, int position, Cursor cursor);

        RecyclerView.ViewHolder onCreateViewHolder(ViewGroup viewGroup, int position);
    }

    public ParallaxCursorRecyclerAdapter(Cursor cursor) {
        init(cursor);
    }

    public interface OnClickEvent {
        /**
         * Event triggered when you click on a item of the adapter
         *
         * @param v        view
         * @param position position on the array
         */
        void onClick(View v, int position);
    }

    public interface OnParallaxScroll {
        /**
         * Event triggered when the parallax is being scrolled.
         *
         * @param percentage
         * @param offset
         * @param parallax
         */
        void onParallaxScroll(float percentage, float offset, View parallax);
    }

    private CustomRelativeWrapper mHeader;
    private RecyclerAdapterMethods mRecyclerAdapterMethods;
    private OnClickEvent mOnClickEvent;
    private OnParallaxScroll mParallaxScroll;
    private RecyclerView mRecyclerView;
    private int mTotalYScrolled;
    private boolean mShouldClipView = true;
    /**
     * ****************************************************************************************************************************
     * CursorAdapter
     */
    private boolean mDataValid;
    private int mRowIDColumn;
    private Cursor mCursor;
    private ChangeObserver mChangeObserver;
    private DataSetObserver mDataSetObserver;
    private CursorFilter mCursorFilter;
    private FilterQueryProvider mFilterQueryProvider;

    void init(Cursor c) {
        boolean cursorPresent = c != null;
        mCursor = c;
        mDataValid = cursorPresent;
        mRowIDColumn = cursorPresent ? c.getColumnIndexOrThrow("_id") : -1;

        mChangeObserver = new ChangeObserver();
        mDataSetObserver = new MyDataSetObserver();

        if (cursorPresent) {
            if (mChangeObserver != null) c.registerContentObserver(mChangeObserver);
            if (mDataSetObserver != null) c.registerDataSetObserver(mDataSetObserver);
        }
    }

    public Cursor getCursor() {
        return mCursor;
    }

    /**
     * Change the underlying cursor to a new cursor. If there is an existing cursor it will be
     * closed.
     *
     * @param cursor The new cursor to be used
     */
    public void changeCursor(Cursor cursor) {
        Cursor old = swapCursor(cursor);
        if (old != null) {
            old.close();
        }
    }

    /**
     * Swap in a new Cursor, returning the old Cursor.  Unlike
     * {@link #changeCursor(Cursor)}, the returned old Cursor is <em>not</em>
     * closed.
     *
     * @param newCursor The new cursor to be used.
     * @return Returns the previously set Cursor, or null if there wasa not one.
     * If the given new Cursor is the same instance is the previously set
     * Cursor, null is also returned.
     */
    public Cursor swapCursor(Cursor newCursor) {
        if (newCursor == mCursor) {
            return null;
        }
        Cursor oldCursor = mCursor;
        if (oldCursor != null) {
            if (mChangeObserver != null) oldCursor.unregisterContentObserver(mChangeObserver);
            if (mDataSetObserver != null) oldCursor.unregisterDataSetObserver(mDataSetObserver);
        }
        mCursor = newCursor;
        if (newCursor != null) {
            if (mChangeObserver != null) newCursor.registerContentObserver(mChangeObserver);
            if (mDataSetObserver != null) newCursor.registerDataSetObserver(mDataSetObserver);
            mRowIDColumn = newCursor.getColumnIndexOrThrow("_id");
            mDataValid = true;
            // notify the observers about the new cursor
            notifyDataSetChanged();
        } else {
            mRowIDColumn = -1;
            mDataValid = false;
            // notify the observers about the lack of a data set
            // notifyDataSetInvalidated();
            notifyItemRangeRemoved(0, getItemCount());
        }
        return oldCursor;
    }

    /**
     * <p>Converts the cursor into a CharSequence. Subclasses should override this
     * method to convert their results. The default implementation returns an
     * empty String for null values or the default String representation of
     * the value.</p>
     *
     * @param cursor the cursor to convert to a CharSequence
     * @return a CharSequence representing the value
     */
    public CharSequence convertToString(Cursor cursor) {
        return cursor == null ? "" : cursor.toString();
    }

    /**
     * Runs a query with the specified constraint. This query is requested
     * by the filter attached to this adapter.
     * <p/>
     * The query is provided by a
     * {@link android.widget.FilterQueryProvider}.
     * If no provider is specified, the current cursor is not filtered and returned.
     * <p/>
     * After this method returns the resulting cursor is passed to {@link #changeCursor(Cursor)}
     * and the previous cursor is closed.
     * <p/>
     * This method is always executed on a background thread, not on the
     * application's main thread (or UI thread.)
     * <p/>
     * Contract: when constraint is null or empty, the original results,
     * prior to any filtering, must be returned.
     *
     * @param constraint the constraint with which the query must be filtered
     * @return a Cursor representing the results of the new query
     * @see #getFilter()
     * @see #getFilterQueryProvider()
     * @see #setFilterQueryProvider(android.widget.FilterQueryProvider)
     */
    public Cursor runQueryOnBackgroundThread(CharSequence constraint) {
        if (mFilterQueryProvider != null) {
            return mFilterQueryProvider.runQuery(constraint);
        }

        return mCursor;
    }

    public Filter getFilter() {
        if (mCursorFilter == null) {
            mCursorFilter = new CursorFilter(this);
        }
        return mCursorFilter;
    }

    /**
     * Returns the query filter provider used for filtering. When the
     * provider is null, no filtering occurs.
     *
     * @return the current filter query provider or null if it does not exist
     * @see #setFilterQueryProvider(android.widget.FilterQueryProvider)
     * @see #runQueryOnBackgroundThread(CharSequence)
     */
    public FilterQueryProvider getFilterQueryProvider() {
        return mFilterQueryProvider;
    }

    /**
     * Sets the query filter provider used to filter the current Cursor.
     * The provider's
     * {@link android.widget.FilterQueryProvider#runQuery(CharSequence)}
     * method is invoked when filtering is requested by a client of
     * this adapter.
     *
     * @param filterQueryProvider the filter query provider or null to remove it
     * @see #getFilterQueryProvider()
     * @see #runQueryOnBackgroundThread(CharSequence)
     */
    public void setFilterQueryProvider(FilterQueryProvider filterQueryProvider) {
        mFilterQueryProvider = filterQueryProvider;
    }

    /**
     * Called when the {@link android.database.ContentObserver} on the cursor receives a change notification.
     * Can be implemented by sub-class.
     *
     * @see android.database.ContentObserver#onChange(boolean)
     */
    protected void onContentChanged() {

    }

/******************************************************************************************************************************
 * ParallaxAdapter
 * **/

     /**
     * Translates the adapter in Y
     *
     * @param of offset in px
     */
    public void translateHeader(float of) {
        float ofCalculated = of * SCROLL_MULTIPLIER;
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.HONEYCOMB) {
            mHeader.setTranslationY(ofCalculated);
        } else {
            TranslateAnimation anim = new TranslateAnimation(0, 0, ofCalculated, ofCalculated);
            anim.setFillAfter(true);
            anim.setDuration(0);
            mHeader.startAnimation(anim);
        }
        mHeader.setClipY(Math.round(ofCalculated));
        if (mParallaxScroll != null) {
            float left = Math.min(1, ((ofCalculated) / (mHeader.getHeight() * SCROLL_MULTIPLIER)));
            mParallaxScroll.onParallaxScroll(left, of, mHeader);
        }
    }

    /**
     * Set the view as header.
     *
     * @param header The inflated header
     * @param view   The RecyclerView to set scroll listeners
     */
    public void setParallaxHeader(View header, final RecyclerView view) {
        mRecyclerView = view;
        mHeader = new CustomRelativeWrapper(header.getContext(), mShouldClipView);
        mHeader.setLayoutParams(new ViewGroup.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT));
        mHeader.addView(header, new RelativeLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT));

        view.setOnScrollListener(new RecyclerView.OnScrollListener() {
            @Override
            public void onScrolled(RecyclerView recyclerView, int dx, int dy) {
                super.onScrolled(recyclerView, dx, dy);
                if (mHeader != null) {
                    mTotalYScrolled += dy;
                    translateHeader(mTotalYScrolled);
                }
            }
        });
    }

    public void removeParallaxHeader() {
        mHeader.removeAllViews();
    }

    @Override
    public void onBindViewHolder(RecyclerView.ViewHolder viewHolder, final int i) {
        if (!mDataValid) {
            throw new IllegalStateException("this should only be called when the cursor is valid");
        }

        if (i > 0 && !mCursor.moveToPosition(i - 1)) {
            throw new IllegalStateException("couldn't move cursor to position " + i);
        }
//        Log.d("BAWDA", "position: " + i + ": holder type: " + viewHolder.getItemViewType() + ": cursor current position: " + mCursor.getPosition());
        if (mRecyclerAdapterMethods == null)
            throw new NullPointerException("You must call implementRecyclerAdapterMethods");
        if (i != 0 && mHeader != null) {
            mRecyclerAdapterMethods.onBindViewHolder(viewHolder, i - 1, mCursor);
        } else if (i != 0)
            mRecyclerAdapterMethods.onBindViewHolder(viewHolder, i, mCursor);
        if (mOnClickEvent != null)
            viewHolder.itemView.setOnClickListener(new View.OnClickListener() {
                @Override
                public void onClick(View v) {
                    mOnClickEvent.onClick(v, i - (mHeader == null ? 0 : 1));
                }
            });
    }

    @Override
    public RecyclerView.ViewHolder onCreateViewHolder(ViewGroup viewGroup, int i) {
        if (mRecyclerAdapterMethods == null)
            throw new NullPointerException("You must call implementRecyclerAdapterMethods");
        if (i == VIEW_TYPES.HEADER && mHeader != null)
            return new ViewHolder(mHeader);
        if (i == VIEW_TYPES.FIRST_VIEW && mHeader != null && mRecyclerView != null) {
            RecyclerView.ViewHolder holder = mRecyclerView.findViewHolderForPosition(0);
            if (holder != null) {
                translateHeader(-holder.itemView.getTop());
                mTotalYScrolled = -holder.itemView.getTop();
            }
        }
        return mRecyclerAdapterMethods.onCreateViewHolder(viewGroup, i);
    }

    /**
     * @return true if there is a header on this adapter, false otherwise
     */
    public boolean hasHeader() {
        return mHeader != null;
    }

    public void setOnClickEvent(OnClickEvent onClickEvent) {
        mOnClickEvent = onClickEvent;
    }

    public boolean isShouldClipView() {
        return mShouldClipView;
    }

    /**
     * Defines if we will clip the layout or not. MUST BE CALLED BEFORE {@link #setParallaxHeader(android.view.View, android.support.v7.widget.RecyclerView)}
     *
     * @param shouldClickView
     */
    public void setShouldClipView(boolean shouldClickView) {
        mShouldClipView = shouldClickView;
    }

    public void setOnParallaxScroll(OnParallaxScroll parallaxScroll) {
        mParallaxScroll = parallaxScroll;
        mParallaxScroll.onParallaxScroll(0, 0, mHeader);
    }

/*    //Required Default Constructor
    public ParallaxRecyclerAdapter() {

    }*/

/*    public List<T> getData() {
        return mData;
    }

    public void setData(List<T> data) {
        mData = data;
        notifyDataSetChanged();
    }*/

/*    public void addItem(T item, int position) {
        mData.add(position, item);
        notifyItemInserted(position + (mHeader == null ? 0 : 1));
    }*/

    public void removeItem(int position) {
        if (position < 0)
            return;
        notifyItemRemoved(position + (mHeader == null ? 0 : 1));
    }

    public int getItemCount() {
        if (mDataValid && mCursor != null) {
            return mCursor.getCount() + (mHeader == null ? 0 : 1);
        } else {
            return 0 + (mHeader == null ? 0 : 1);
        }
    }

    @Override
    public int getItemViewType(int position) {
        if (mRecyclerAdapterMethods == null)
            throw new NullPointerException("You must call implementRecyclerAdapterMethods");
        if (position == 1)
            return VIEW_TYPES.FIRST_VIEW;
        return position == 0 ? VIEW_TYPES.HEADER : VIEW_TYPES.NORMAL;
    }

    /**
     * @see android.widget.ListAdapter#getItemId(int)
     */
    @Override
    public long getItemId(int position) {
        if (mDataValid && mCursor != null) {
            if (mCursor.moveToPosition(position)) {
                return mCursor.getLong(mRowIDColumn);
            } else {
                return 0;
            }
        } else {
            return 0;
        }
    }

    /**
     * You must call this method to set your normal adapter methods
     *
     * @param callbacks
     */
    public void implementRecyclerAdapterMethods(RecyclerAdapterMethods callbacks) {
        mRecyclerAdapterMethods = callbacks;
    }

    static class ViewHolder extends RecyclerView.ViewHolder {
        public ViewHolder(View itemView) {
            super(itemView);
        }
    }

    static class CustomRelativeWrapper extends RelativeLayout {

        private int mOffset;
        private boolean mShouldClip;

        public CustomRelativeWrapper(Context context, boolean shouldClick) {
            super(context);
            mShouldClip = shouldClick;
        }

        @Override
        protected void dispatchDraw(Canvas canvas) {
            if (mShouldClip) {
                canvas.clipRect(new Rect(getLeft(), getTop(), getRight(), getBottom() + mOffset));
            }
            super.dispatchDraw(canvas);
        }

        public void setClipY(int offset) {
            mOffset = offset;
            invalidate();
        }
    }

    /**
     * ****************************************************************************************************************************
     */

    private class ChangeObserver extends ContentObserver {
        public ChangeObserver() {
            super(new Handler());
        }

        @Override
        public boolean deliverSelfNotifications() {
            return true;
        }

        @Override
        public void onChange(boolean selfChange) {
            onContentChanged();
        }
    }

    private class MyDataSetObserver extends DataSetObserver {
        @Override
        public void onChanged() {
            mDataValid = true;
            notifyDataSetChanged();
        }

        @Override
        public void onInvalidated() {
            mDataValid = false;
            // notifyDataSetInvalidated();
            notifyItemRangeRemoved(0, getItemCount());
        }
    }

}

class CursorFilter extends Filter {

    CursorFilterClient mClient;

    interface CursorFilterClient {
        CharSequence convertToString(Cursor cursor);

        Cursor runQueryOnBackgroundThread(CharSequence constraint);

        Cursor getCursor();

        void changeCursor(Cursor cursor);
    }

    CursorFilter(CursorFilterClient client) {
        mClient = client;
    }

    @Override
    public CharSequence convertResultToString(Object resultValue) {
        return mClient.convertToString((Cursor) resultValue);
    }

    @Override
    protected FilterResults performFiltering(CharSequence constraint) {
        Cursor cursor = mClient.runQueryOnBackgroundThread(constraint);

        FilterResults results = new FilterResults();
        if (cursor != null) {
            results.count = cursor.getCount();
            results.values = cursor;
        } else {
            results.count = 0;
            results.values = null;
        }
        return results;
    }

    @Override
    protected void publishResults(CharSequence constraint, FilterResults results) {
        Cursor oldCursor = mClient.getCursor();

        if (results.values != null && results.values != oldCursor) {
            mClient.changeCursor((Cursor) results.values);
        }
    }
}
manujosephv commented 9 years ago

I have bad news my friend.. I tried that fix. It seemed to be working in the beginning until i scrolled.

This is what I get now

image

image

I also get the initial problem sometimes....

kanytu commented 9 years ago

Can you make a little more tests please?

I tested and sometimes it's working and sometimes it's not. It depends on the scroll position. Also what item are you removing?

I will keep testing tomorrow and hopeful find a fix.

manujosephv commented 9 years ago

Sure man. I'll do some more testing and get back to you. On Mar 24, 2015 11:47 PM, "Pedro Oliveira" notifications@github.com wrote:

Can you make a little more tests please?

I tested and sometimes it's working and sometimes it's not. It depends on the scroll position. Also what item are you removing?

I will keep testing tomorrow and hopeful find a fix.

— Reply to this email directly or view it on GitHub https://github.com/kanytu/android-parallax-recyclerview/issues/13#issuecomment-85629108 .

manujosephv commented 9 years ago

Hey,

I did some testing. There are two different undesirable behavior -

  1. Overlap of content with the header
  2. Empty space (equal to the row height) just below the header.

These are some conditions when I got these screens

  1. Scroll Position : Top, Delete top item : OK
  2. Scroll Position : Top, Delete second item : Empty Space
  3. Scroll Position : Mid(header visible), Delete first item : OK
  4. Scroll Position : Mid(header visible), Delete second item : Overlap
  5. Scroll Position: Min (header not visible), Delete a middle item(not first, not last) : Empty Space
  6. Scroll Position: Bottom, delete any item: Overlap

Hope this helps.

On Wed, Mar 25, 2015 at 9:31 AM, Manu Joseph manujosephv@gmail.com wrote:

Sure man. I'll do some more testing and get back to you. On Mar 24, 2015 11:47 PM, "Pedro Oliveira" notifications@github.com wrote:

Can you make a little more tests please?

I tested and sometimes it's working and sometimes it's not. It depends on the scroll position. Also what item are you removing?

I will keep testing tomorrow and hopeful find a fix.

— Reply to this email directly or view it on GitHub https://github.com/kanytu/android-parallax-recyclerview/issues/13#issuecomment-85629108 .

kanytu commented 9 years ago

Hi again :)

Unfortunately this will take some effort to fix. The problem is that mTotalYScrolled field. I'm relying on it to translate the header but it will not work when you remove or add items. If your app doesn't support orientation changes (and that's why there is a mTotalYScrolled there in the first place) you can try to use the older method to translate the header.

Change the setOnScrollListener on the adapter to:

    view.setOnScrollListener(new RecyclerView.OnScrollListener() {
        @Override
        public void onScrolled(RecyclerView recyclerView, int dx, int dy) {
            super.onScrolled(recyclerView, dx, dy);
            if (mHeader != null) {
                ViewHolder holder = (com.poliveira.parallaxrecycleradapter.ParallaxRecyclerAdapter.ViewHolder) recyclerView.findViewHolderForPosition(0);
                if (holder != null)
                    translateHeader(-holder.itemView.getTop() * 0.5f);
                //mTotalYScrolled += dy;
                //translateHeader(dy);
            }
        }
    }); 

You might need to change that cast.

Next change on the translateHeader method the line:

float ofCalculated = of;// * SCROLL_MULTIPLIER;

Perform some tests and come back to me. I will, once I have the time to, change the whole library to not rely on scroll positions.

manujosephv commented 9 years ago

I am not currently supporting orientation changes, but mainly cause the app is in development phase. But I plan to support orientation change eventually. I'll be able to test it only after a couple of days. Going on a vacation... LOL..

P.S - I think I'll just replace the whole adapter in the recyclerview once something is deleted (temporarily). That seems to work.

P.P.S - On a totally different note, I was trying to implement Activity Transitions in my app. Is it possible to split the RecyclerView and slide them off the screen? (Header goes out the top and the rest of the list items off the bottom) Just a thought. I think I can get the header to slide off since I have a reference to it. Not sure about the other list items. I will try that once I'm back from vacation and let you know. Cheers and keep up the good work :)

On Wed, Mar 25, 2015 at 11:42 PM, Pedro Oliveira notifications@github.com wrote:

Hi again :)

Unfortunately this will take some effort to fix. The problem is that mTotalYScrolled field. I'm relying on it to translate the header but it will not work when you remove or add items. If your app doesn't support orientation changes (and that's why there is a mTotalYScrolled there in the first place) you can try to use the older method to translate the header.

Change the setOnScrollListener on the adapter to:

view.setOnScrollListener(new RecyclerView.OnScrollListener() {
    @Override
    public void onScrolled(RecyclerView recyclerView, int dx, int dy) {
        super.onScrolled(recyclerView, dx, dy);
        if (mHeader != null) {
            ViewHolder holder = (com.poliveira.parallaxrecycleradapter.ParallaxRecyclerAdapter.ViewHolder) recyclerView.findViewHolderForPosition(0);
            if (holder != null)
                translateHeader(-holder.itemView.getTop() * 0.5f);
            //mTotalYScrolled += dy;
            //translateHeader(dy);
        }
    }
});

You might need to change that cast.

Next change on the translateHeader method the line:

float ofCalculated = of;// * SCROLL_MULTIPLIER;

Perform some tests and come back to me. I will, once I have the time to, change the whole library to not rely on scroll positions.

— Reply to this email directly or view it on GitHub https://github.com/kanytu/android-parallax-recyclerview/issues/13#issuecomment-86154807 .