osama-raddad
11/15/2017 - 9:00 AM

A fix for https://github.com/rubensousa/RecyclerViewSnap/ when used in RTL carousels. See https://github.com/rubensousa/RecyclerViewSnap/iss

import android.support.annotation.NonNull;
import android.support.annotation.Nullable;
import android.support.v7.widget.LinearLayoutManager;
import android.support.v7.widget.LinearSnapHelper;
import android.support.v7.widget.OrientationHelper;
import android.support.v7.widget.RecyclerView;
import android.support.v7.widget.RecyclerView.LayoutManager;
import android.view.Gravity;
import android.view.View;

import com.example.AppState;

/**
 * A {@link LinearSnapHelper} that allows you to snap to a certain edge of the {@link RecyclerView},
 * as opposed to the default center-snapping provided by {@link LinearSnapHelper}.
 *
 * Inspired by https://github.com/rubensousa/RecyclerViewSnap/
 */
public class GravitySnapHelper extends LinearSnapHelper {

    @Nullable private OrientationHelper verticalHelper;
    @Nullable private OrientationHelper horizontalHelper;
    private int gravity;

    //TODO Google's OrientationHelper seems to be broken in RTL carousels. Check if these flags are still needed when updating support lib.
    private boolean isRTL = AppState.getAppComponent().localeManager().isCurrentLocaleRTL();
    private boolean isGravityHorizontal;
    
    private boolean isFlingingInPositiveDirection;

    /**
     * Constructor
     *
     * @param gravity The {@link Gravity} specifies the edge of the {@link RecyclerView} that items should snap to.
     */
    public GravitySnapHelper(int gravity) {
        switch (gravity) {
            case Gravity.LEFT:
            case Gravity.START:
                this.gravity = Gravity.START;
                this.isGravityHorizontal = true;
                break;
            case Gravity.RIGHT:
            case Gravity.END:
                this.gravity = Gravity.END;
                this.isGravityHorizontal = true;
                break;
            case Gravity.TOP:
            case Gravity.BOTTOM:
                this.gravity = gravity;
                break;
            default:
                throw new IllegalArgumentException("Illegal Gravity passed to constructor.");
        }
    }

    @Override
    @Nullable
    public int[] calculateDistanceToFinalSnap(@NonNull LayoutManager layoutManager, @NonNull View targetView) {
        int[] out = new int[2];

        if (layoutManager.canScrollHorizontally()) {
            if (gravity == Gravity.START) {
                out[0] = distanceToStart(targetView, getHorizontalHelper(layoutManager));
            } else { // END
                out[0] = distanceToEnd(targetView, getHorizontalHelper(layoutManager));
            }
        } else {
            out[0] = 0;
        }

        if (layoutManager.canScrollVertically()) {
            if (gravity == Gravity.TOP) {
                out[1] = distanceToStart(targetView, getVerticalHelper(layoutManager));
            } else { // BOTTOM
                out[1] = distanceToEnd(targetView, getVerticalHelper(layoutManager));
            }
        } else {
            out[1] = 0;
        }
        return out;
    }

    @Override
    @Nullable
    public View findSnapView(LayoutManager layoutManager) {
        if (layoutManager instanceof LinearLayoutManager) {
            switch (gravity) {
                case Gravity.START:
                    return findStartView(layoutManager, getHorizontalHelper(layoutManager));
                case Gravity.TOP:
                    return findStartView(layoutManager, getVerticalHelper(layoutManager));
                case Gravity.END:
                    return findEndView(layoutManager, getHorizontalHelper(layoutManager));
                case Gravity.BOTTOM:
                    return findEndView(layoutManager, getVerticalHelper(layoutManager));
                default:
                    break;
            }
        }

        return super.findSnapView(layoutManager);
    }
    
    @Override
    public int findTargetSnapPosition(LayoutManager layoutManager, int velocityX, int velocityY) {
        isFlingingInPositiveDirection = isRTL ? Math.max(velocityX, velocityY) < 0 : Math.max(velocityX, velocityY) > 0;
        return super.findTargetSnapPosition(layoutManager, velocityX, velocityY);
    }

    /**
     * Calculates the passed view's distance from the start of this parent.
     */
    private int distanceToStart(View targetView, OrientationHelper helper) {
        if (isRTL && isGravityHorizontal) {
            //TODO Check if this is still needed after updating support library. The helper currently assumes start == left and right == end.
            return helper.getDecoratedEnd(targetView) - helper.getEndAfterPadding();
        }
        return helper.getDecoratedStart(targetView) - helper.getStartAfterPadding();
    }

    /**
     * Calculates the passed view's distance from the end of this parent.
     */
    private int distanceToEnd(View targetView, OrientationHelper helper) {
        if (isRTL && isGravityHorizontal) {
            //TODO Check if this is still needed after updating support library. The helper currently assumes start == left and right == end.
            return helper.getDecoratedStart(targetView) - helper.getStartAfterPadding();
        }
        return helper.getDecoratedEnd(targetView) - helper.getEndAfterPadding();
    }

    /**
     * Returns the child view that is currently closest to the start of this parent.
     *
     * @param layoutManager The {@link LayoutManager} associated with the attached
     *                      {@link RecyclerView}.
     * @param helper The relevant {@link OrientationHelper} for the attached {@link RecyclerView}.
     *
     * @return the child view that is currently closest to the start of this parent.
     */
    @Nullable
    private View findStartView(LayoutManager layoutManager, OrientationHelper helper) {

        if (layoutManager instanceof LinearLayoutManager) {
            int firstChild = ((LinearLayoutManager) layoutManager).findFirstVisibleItemPosition();

            if (firstChild == RecyclerView.NO_POSITION) {
                return null;
            }

            View child = layoutManager.findViewByPosition(firstChild);

            //TODO Check if this is still needed after updating support library. The helper currently assumes start == left and right == end.
            boolean shouldReturnChild;
            if (isRTL && isGravityHorizontal) {
                shouldReturnChild = helper.getDecoratedStart(child) <= helper.getDecoratedMeasurement(child) / 2;
            } else {
                shouldReturnChild = helper.getDecoratedEnd(child) >= helper.getDecoratedMeasurement(child) / 2;
            }

            if (isFlingingInPositiveDirection
                    && ((LinearLayoutManager) layoutManager).findLastCompletelyVisibleItemPosition() == layoutManager.getItemCount() - 1) {
                return null;
            } else if (shouldReturnChild) {
                return child;
            } else {
                return layoutManager.findViewByPosition(firstChild + 1);
            }
        }

        return super.findSnapView(layoutManager);
    }

    /**
     * Returns the child view that is currently closest to the end of this parent.
     *
     * @param layoutManager The {@link LayoutManager} associated with the attached
     *                      {@link RecyclerView}.
     * @param helper The relevant {@link OrientationHelper} for the attached {@link RecyclerView}.
     *
     * @return the child view that is currently closest to the end of this parent.
     */
    @Nullable
    private View findEndView(LayoutManager layoutManager, OrientationHelper helper) {

        if (layoutManager instanceof LinearLayoutManager) {
            int lastChild = ((LinearLayoutManager) layoutManager).findLastVisibleItemPosition();

            if (lastChild == RecyclerView.NO_POSITION) {
                return null;
            }

            View child = layoutManager.findViewByPosition(lastChild);

            //TODO Check if this is still needed after updating support library. The helper currently assumes start == left and right == end.
            boolean shouldReturnChild;
            if (isRTL && isGravityHorizontal) {
                shouldReturnChild = helper.getDecoratedEnd(child) - helper.getDecoratedMeasurement(child) / 2 >= layoutManager.getPaddingLeft();
            } else {
                shouldReturnChild = helper.getDecoratedStart(child) + helper.getDecoratedMeasurement(child) / 2 <= helper.getTotalSpace();
            }

            if (!isFlingingInPositiveDirection
                    && ((LinearLayoutManager) layoutManager).findFirstCompletelyVisibleItemPosition() == 0) {
                return null;
            } else if (shouldReturnChild) {
                return child;
            } else {
                return layoutManager.findViewByPosition(lastChild - 1);
            }
        }

        return super.findSnapView(layoutManager);
    }

    /**
     * Creates and returns a vertical {@link OrientationHelper} for the passed {@link LayoutManager}.
     */
    @NonNull
    private OrientationHelper getVerticalHelper(LayoutManager layoutManager) {
        if (verticalHelper == null) {
            verticalHelper = OrientationHelper.createVerticalHelper(layoutManager);
        }
        return verticalHelper;
    }

    /**
     * Creates and returns a horizontal {@link OrientationHelper} for the passed {@link LayoutManager}.
     */
    @NonNull
    private OrientationHelper getHorizontalHelper(LayoutManager layoutManager) {
        if (horizontalHelper == null) {
            horizontalHelper = OrientationHelper.createHorizontalHelper(layoutManager);
        }
        return horizontalHelper;
    }
}