Donate to e Foundation | Murena handsets with /e/OS | Own a part of Murena! Learn more

Commit 5bf51ec2 authored by Steve McKay's avatar Steve McKay
Browse files

Recycler+Selection documentation and API fixups.

This *should* be the last in-situ code review for the RecyclerView selection API.
My main goal with this change:

    Ensure support lib folks are happy with the basic shape of the API.
    If not, no worries, let's reshaping the API here where the code lives
    in DocumetnsUI. That'll help ensure any changes will retain
    the capabilities necessary to accommodate the more nuanced behaviors
    defined in DocumentsUI, and help provide more detailed example usage
    to support-lib folks prior to our pulling the code over to support-lib
    for final review.

Note that there is fairly substantial, though informal, documentation on each
class and its role in SelectionDemoActivity. That's a great place to start for
reviewer.

Once folks are generally happy w/ the shape. I'll pull a copy of the code
over to the support lib package where we can continue the reivew.
I'll polish off documentation (especially adding package docs) at that time.

Demo video at: https://drive.google.com/a/google.com/file/d/0B1OqiAcKh66ZTHJhU0xSa1lobmM/view?usp=sharing

Bug: 64847011
Test: Added new GestureSelectionHelper coverage. All else passing.
Change-Id: I86f268ce92929cfc490d92100b7bcf158cd4a7dd
parent baa1341c
Loading
Loading
Loading
Loading
+4 −6
Original line number Diff line number Diff line
@@ -86,7 +86,6 @@ import com.android.documentsui.clipping.DocumentClipper;
import com.android.documentsui.clipping.UrisSupplier;
import com.android.documentsui.dirlist.AnimationView.AnimationType;
import com.android.documentsui.picker.PickActivity;
import com.android.documentsui.selection.BandPredicate;
import com.android.documentsui.selection.BandSelectionHelper;
import com.android.documentsui.selection.ContentLock;
import com.android.documentsui.selection.DefaultBandHost;
@@ -98,9 +97,9 @@ import com.android.documentsui.selection.MotionInputHandler;
import com.android.documentsui.selection.MouseInputHandler;
import com.android.documentsui.selection.Selection;
import com.android.documentsui.selection.SelectionHelper;
import com.android.documentsui.selection.SelectionHelper.SelectionPredicate;
import com.android.documentsui.selection.TouchEventRouter;
import com.android.documentsui.selection.TouchInputHandler;
import com.android.documentsui.selection.SelectionHelper.SelectionPredicate;
import com.android.documentsui.services.FileOperation;
import com.android.documentsui.services.FileOperationService;
import com.android.documentsui.services.FileOperationService.OpType;
@@ -340,14 +339,13 @@ public class DirectoryFragment extends Fragment implements SwipeRefreshLayout.On
                GestureSelectionHelper.create(mSelectionMgr, mRecView, mContentLock);

        if (mState.allowMultiple) {
            BandPredicate bandPredicate = new DefaultBandPredicate(mDetailsLookup);

            mBandSelector = new BandSelectionHelper(
                    new DefaultBandHost(mRecView, R.drawable.band_select_overlay, bandPredicate),
                    new DefaultBandHost(mRecView, R.drawable.band_select_overlay),
                    mAdapter,
                    new DocsStableIdProvider(mAdapter),
                    mSelectionMgr,
                    selectionPredicate,
                    new DefaultBandPredicate(mDetailsLookup),
                    mContentLock);

            mBandSelectStartedCallback = mFocusManager::clearFocus;
@@ -556,7 +554,7 @@ public class DirectoryFragment extends Fragment implements SwipeRefreshLayout.On
        mRecView.setPadding(pad, pad, pad, pad);
        mRecView.requestLayout();
        if (mBandSelector != null) {
            mBandSelector.onLayoutChanged();
            mBandSelector.reset();
        }
        mIconHelper.setViewMode(mode);
    }
+15 −11
Original line number Diff line number Diff line
@@ -15,38 +15,42 @@
 */
package com.android.documentsui.selection;

import static android.support.v4.util.Preconditions.checkArgument;

import android.support.v7.widget.RecyclerView;
import android.view.MotionEvent;
import android.view.View;

/**
 * Provides clients a means of controlling when and where band selection is initiated.
 * Provides a means of controlling when and where band selection is initiated.
 * This can be used to permit band initiation in non-empty areas, like in the whitespace of
 * a bound view.
 * a bound view. This is especially useful when there is no empty space between items.
 */
public abstract class BandPredicate {
    /**
     * @return true if band selection can be initiated in repsonse to the {@link MotionEvent}.
     */

    /** @return true if band selection can be initiated in response to the {@link MotionEvent}. */
    public abstract boolean canInitiate(MotionEvent e);

    /**
     * A BandPredicate that allows initiation of band selection only in areas of RecyclerView
     * where there are no items or the item position is {@link RecyclerView#NO_POSITION}.
     * that have {@link RecyclerView#NO_POSITION}. In most cases, this will be the empty areas
     * between views.
     */
    public static final class NoPositionBandPredicate extends BandPredicate {

        private final RecyclerView mView;
        private final RecyclerView mRecView;

        public NoPositionBandPredicate(RecyclerView recView) {
            checkArgument(recView != null);

        public NoPositionBandPredicate(RecyclerView view) {
            mView = view;
            mRecView = recView;
        }

        @Override
        public boolean canInitiate(MotionEvent e) {
            View itemView = mView.findChildViewUnder(e.getX(), e.getY());
            View itemView = mRecView.findChildViewUnder(e.getX(), e.getY());
            int position = itemView != null
                    ? mView.getChildAdapterPosition(itemView)
                    ? mRecView.getChildAdapterPosition(itemView)
                    : RecyclerView.NO_POSITION;

            return position == RecyclerView.NO_POSITION;
+62 −51
Original line number Diff line number Diff line
@@ -21,6 +21,7 @@ import static android.support.v4.util.Preconditions.checkState;

import android.graphics.Point;
import android.graphics.Rect;
import android.os.Build;
import android.support.annotation.Nullable;
import android.support.annotation.VisibleForTesting;
import android.support.v7.widget.RecyclerView;
@@ -39,13 +40,15 @@ import java.util.List;
import java.util.Set;

/**
 * Provides mouse driven band-selection support when used in conjunction with
 * a {@link RecyclerView} instance and a {@link SelectionHelper}. This class is responsible
 * for rendering a band overlay and manipulating selection status of the items it intersects with.
 * Provides mouse driven band-selection support when used in conjunction with a {@link RecyclerView}
 * instance. This class is responsible for rendering a band overlay and manipulating selection
 * status of the items it intersects with.
 *
 * <p>Usage:
 *
 * <p><pre>TODO</pre>
 * <p> Given the recycling nature of RecyclerView items that have scrolled off-screen would not
 * be selectable with a band that itself was partially rendered off-screen. To address this,
 * BandSelectionController builds a model of the list/grid information presented by RecyclerView as
 * the user interacts with items using their pointer (and the band). Selectable items that intersect
 * with the band, both on and off screen, are selected on pointer up.
 */
public class BandSelectionHelper implements OnItemTouchListener {

@@ -56,8 +59,8 @@ public class BandSelectionHelper implements OnItemTouchListener {
    private final StableIdProvider mStableIds;
    private final RecyclerView.Adapter<?> mAdapter;
    private final SelectionHelper mSelectionHelper;
    private final Selection mSelection;
    private final SelectionPredicate mSelectionPredicate;
    private final BandPredicate mBandPredicate;
    private final ContentLock mLock;
    private final Runnable mViewScroller;
    private final GridModel.SelectionObserver mGridObserver;
@@ -74,6 +77,7 @@ public class BandSelectionHelper implements OnItemTouchListener {
            StableIdProvider stableIds,
            SelectionHelper selectionHelper,
            SelectionPredicate selectionPredicate,
            BandPredicate bandPredicate,
            ContentLock lock) {

        checkArgument(host != null);
@@ -81,6 +85,7 @@ public class BandSelectionHelper implements OnItemTouchListener {
        checkArgument(stableIds != null);
        checkArgument(selectionHelper != null);
        checkArgument(selectionPredicate != null);
        checkArgument(bandPredicate != null);
        checkArgument(lock != null);

        mHost = host;
@@ -88,10 +93,9 @@ public class BandSelectionHelper implements OnItemTouchListener {
        mAdapter = adapter;
        mSelectionHelper = selectionHelper;
        mSelectionPredicate = selectionPredicate;
        mBandPredicate = bandPredicate;
        mLock = lock;

        mSelection = selectionHelper.getSelection();

        mHost.addOnScrollListener(
                new OnScrollListener() {
                    @Override
@@ -163,24 +167,21 @@ public class BandSelectionHelper implements OnItemTouchListener {
            };
    }

    public void createModel() {
        if (mModel != null) {
            mModel.onDestroy();
        }

        mModel = new GridModel(mHost, mStableIds, mSelectionPredicate);
        mModel.addOnSelectionChangedListener(mGridObserver);
    }

    @VisibleForTesting
    boolean isActive() {
        return mModel != null;
        boolean active = mModel != null;
        if (Build.IS_DEBUGGABLE && active) {
            mLock.checkLocked();
        }
        return active;
    }

    /**
     * Adds a new listener to be notified when band is created.
     */
    public void addOnBandStartedListener(Runnable listener) {
        checkArgument(listener != null);

        mBandStartedListeners.add(listener);
    }

@@ -192,16 +193,23 @@ public class BandSelectionHelper implements OnItemTouchListener {
    }

    /**
     * Clients must call this when there are any material changes to the layout of items
     * Clients must call reset when there are any material changes to the layout of items
     * in RecyclerView.
     */
    public void onLayoutChanged() {
        if (mModel != null) {
            createModel();
    public void reset() {
        if (!isActive()) {
            return;
        }

        mHost.hideBand();
        mModel.stopCapturing();
        mModel.onDestroy();
        mModel = null;
        mOrigin = null;
        mLock.unblock();
    }

    public boolean shouldStart(MotionEvent e) {
    boolean shouldStart(MotionEvent e) {
        // Don't start, or extend bands on non-left clicks.
        if (!MotionEvents.isPrimaryButtonPressed(e)) {
            return false;
@@ -226,7 +234,7 @@ public class BandSelectionHelper implements OnItemTouchListener {
                // associated with files. Checking against actual modelIds count
                // effectively ignores those UI layout items.
                && !mStableIds.getStableIds().isEmpty()
                && mHost.canInitiateBand(e);
                && mBandPredicate.canInitiate(e);
    }

    public boolean shouldStop(MotionEvent e) {
@@ -281,7 +289,7 @@ public class BandSelectionHelper implements OnItemTouchListener {
        mModel.resizeSelection(mCurrentPosition);

        scrollViewIfNecessary();
        resizeBandSelectRectangle();
        resizeBand();
    }

    @Override
@@ -293,22 +301,22 @@ public class BandSelectionHelper implements OnItemTouchListener {
    private void startBandSelect(Point origin) {
        if (DEBUG) Log.d(TAG, "Starting band select @ " + origin);

        reset();
        mModel = new GridModel(mHost, mStableIds, mSelectionPredicate);
        mModel.addOnSelectionChangedListener(mGridObserver);

        mLock.block();
        onBandStarted();
        notifyBandStarted();
        mOrigin = origin;
        createModel();
        mModel.startSelection(mOrigin);
        mModel.startCapturing(mOrigin);
    }

    private void onBandStarted() {
    private void notifyBandStarted() {
        for (Runnable listener : mBandStartedListeners) {
            listener.run();
        }
    }

    /**
     * Scrolls the view if necessary.
     */
    private void scrollViewIfNecessary() {
        mHost.removeCallback(mViewScroller);
        mViewScroller.run();
@@ -319,11 +327,12 @@ public class BandSelectionHelper implements OnItemTouchListener {
     * Resizes the band select rectangle by using the origin and the current pointer position as
     * two opposite corners of the selection.
     */
    private void resizeBandSelectRectangle() {
    private void resizeBand() {
        mBounds = new Rect(Math.min(mOrigin.x, mCurrentPosition.x),
                Math.min(mOrigin.y, mCurrentPosition.y),
                Math.max(mOrigin.x, mCurrentPosition.x),
                Math.max(mOrigin.y, mCurrentPosition.y));

        mHost.showBand(mBounds);
    }

@@ -333,26 +342,29 @@ public class BandSelectionHelper implements OnItemTouchListener {
    private void endBandSelect() {
        if (DEBUG) Log.d(TAG, "Ending band select.");

        mHost.hideBand();
        mSelectionHelper.mergeProvisionalSelection();
        mModel.endSelection();
        // TODO: Currently when a band select operation ends outside
        // of an item (e.g. in the empty area between items),
        // getPositionNearestOrigin may return an unselected item.
        // Since the point of this code is to establish the
        // anchor point for subsequent range operations (SHIFT+CLICK)
        // we really want to do a better job figuring out the last
        // item selected (and nearest to the cursor).
        int firstSelected = mModel.getPositionNearestOrigin();
        if (firstSelected != GridModel.NOT_SET) {
            if (mSelection.contains(mStableIds.getStableId(firstSelected))) {
                // TODO: firstSelected should really be lastSelected, we want to anchor the item
                // where the mouse-up occurred.
        if (firstSelected != GridModel.NOT_SET
                && mSelectionHelper.isSelected(mStableIds.getStableId(firstSelected))) {
            // Establish the band selection point as range anchor. This
            // allows touch and keyboard based selection activities
            // to be based on the band selection anchor point.
            mSelectionHelper.anchorRange(firstSelected);
            } else {
                // TODO: Check if this is really happening.
                Log.w(TAG, "First selected by band is NOT in selection!");
            }
        }

        mModel = null;
        mOrigin = null;
        mLock.unblock();
        mSelectionHelper.mergeProvisionalSelection();
        reset();
    }

    /**
     * @see RecyclerView.OnScrollListener
     */
    private void onScrolled(RecyclerView recyclerView, int dx, int dy) {
        if (!isActive()) {
            return;
@@ -361,7 +373,7 @@ public class BandSelectionHelper implements OnItemTouchListener {
        // Adjust the y-coordinate of the origin the opposite number of pixels so that the
        // origin remains in the same place relative to the view's items.
        mOrigin.y -= dy;
        resizeBandSelectRectangle();
        resizeBand();
    }

    /**
@@ -369,7 +381,6 @@ public class BandSelectionHelper implements OnItemTouchListener {
     * fully isolated from RecyclerView.
     */
    public static abstract class BandHost extends ScrollerCallbacks {
        public abstract boolean canInitiateBand(MotionEvent e);
        public abstract void showBand(Rect rect);
        public abstract void hideBand();
        public abstract void addOnScrollListener(RecyclerView.OnScrollListener listener);
+18 −5
Original line number Diff line number Diff line
@@ -21,13 +21,16 @@ import static com.android.documentsui.selection.Shared.TAG;

import android.annotation.MainThread;
import android.annotation.Nullable;
import android.content.Loader;
import android.util.Log;

/**
 * A lock used by {@link BandSelectionHelper} and {@link GestureSelectionHelper} to signal to
 * clients when selection is in-progress. While locked, clients should block changes to content.
 * ContentLock provides a mechanism to block content from reloading while selection
 * activities like gesture and band selection are active. Clients using live data
 * (data loaded, for example by a {@link Loader}), should route calls to load
 * content through this lock using {@link ContentLock#runWhenUnlocked(Runnable)}.
 */
public class ContentLock {
public final class ContentLock {

    private int mLocks = 0;
    private @Nullable Runnable mCallback;
@@ -70,7 +73,17 @@ public class ContentLock {
        }
    }

    final boolean isLocked() {
        return mLocks > 0;
    /**
     * Allows other selection code to perform a precondition check asserting the state is locked.
     */
    final void checkLocked() {
        checkState(mLocks > 0);
    }

    /**
     * Allows other selection code to perform a precondition check asserting the state is unlocked.
     */
    final void checkUnlocked() {
        checkState(mLocks == 0);
    }
}
+26 −38
Original line number Diff line number Diff line
@@ -23,7 +23,6 @@ import android.graphics.Rect;
import android.graphics.drawable.Drawable;
import android.support.v7.widget.GridLayoutManager;
import android.support.v7.widget.RecyclerView;
import android.view.MotionEvent;
import android.view.View;

import com.android.documentsui.selection.BandSelectionHelper.BandHost;
@@ -33,78 +32,67 @@ import com.android.documentsui.selection.BandSelectionHelper.BandHost;
 */
public final class DefaultBandHost extends BandHost {

    private final RecyclerView mView;
    private final RecyclerView mRecView;
    private final Drawable mBand;
    private final BandPredicate mBandPredicate;

    private boolean mIsOverlayShown;

    public DefaultBandHost(
            RecyclerView view,
            @DrawableRes int bandOverlayId,
            BandPredicate bandPredicate) {
    public DefaultBandHost(RecyclerView recView, @DrawableRes int bandOverlayId) {

        checkArgument(view != null);
        checkArgument(bandPredicate != null);
        checkArgument(recView != null);

        mView = view;
        mBandPredicate = bandPredicate;
        mBand = mView.getContext().getTheme().getDrawable(bandOverlayId);
        mRecView = recView;
        mBand = mRecView.getContext().getTheme().getDrawable(bandOverlayId);

        checkArgument(mBand != null);
    }

    @Override
    public boolean canInitiateBand(MotionEvent e) {
        return mBandPredicate.canInitiate(e);
    }

    @Override
    public int getAdapterPositionAt(int index) {
        return mView.getChildAdapterPosition(mView.getChildAt(index));
        return mRecView.getChildAdapterPosition(mRecView.getChildAt(index));
    }

    @Override
    public void addOnScrollListener(RecyclerView.OnScrollListener listener) {
        mView.addOnScrollListener(listener);
        mRecView.addOnScrollListener(listener);
    }

    @Override
    public void removeOnScrollListener(RecyclerView.OnScrollListener listener) {
        mView.removeOnScrollListener(listener);
        mRecView.removeOnScrollListener(listener);
    }

    @Override
    public Point createAbsolutePoint(Point relativePoint) {
        return new Point(relativePoint.x + mView.computeHorizontalScrollOffset(),
                relativePoint.y + mView.computeVerticalScrollOffset());
        return new Point(relativePoint.x + mRecView.computeHorizontalScrollOffset(),
                relativePoint.y + mRecView.computeVerticalScrollOffset());
    }

    @Override
    public Rect getAbsoluteRectForChildViewAt(int index) {
        final View child = mView.getChildAt(index);
        final View child = mRecView.getChildAt(index);
        final Rect childRect = new Rect();
        child.getHitRect(childRect);
        childRect.left += mView.computeHorizontalScrollOffset();
        childRect.right += mView.computeHorizontalScrollOffset();
        childRect.top += mView.computeVerticalScrollOffset();
        childRect.bottom += mView.computeVerticalScrollOffset();
        childRect.left += mRecView.computeHorizontalScrollOffset();
        childRect.right += mRecView.computeHorizontalScrollOffset();
        childRect.top += mRecView.computeVerticalScrollOffset();
        childRect.bottom += mRecView.computeVerticalScrollOffset();
        return childRect;
    }

    @Override
    public int getChildCount() {
        return mView.getAdapter().getItemCount();
        return mRecView.getAdapter().getItemCount();
    }

    @Override
    public int getVisibleChildCount() {
        return mView.getChildCount();
        return mRecView.getChildCount();
    }

    @Override
    public int getColumnCount() {
        RecyclerView.LayoutManager layoutManager = mView.getLayoutManager();
        RecyclerView.LayoutManager layoutManager = mRecView.getLayoutManager();
        if (layoutManager instanceof GridLayoutManager) {
            return ((GridLayoutManager) layoutManager).getSpanCount();
        }
@@ -115,27 +103,27 @@ public final class DefaultBandHost extends BandHost {

    @Override
    public int getHeight() {
        return mView.getHeight();
        return mRecView.getHeight();
    }

    @Override
    public void invalidateView() {
        mView.invalidate();
        mRecView.invalidate();
    }

    @Override
    public void runAtNextFrame(Runnable r) {
        mView.postOnAnimation(r);
        mRecView.postOnAnimation(r);
    }

    @Override
    public void removeCallback(Runnable r) {
        mView.removeCallbacks(r);
        mRecView.removeCallbacks(r);
    }

    @Override
    public void scrollBy(int dy) {
        mView.scrollBy(0, dy);
        mRecView.scrollBy(0, dy);
    }

    @Override
@@ -143,17 +131,17 @@ public final class DefaultBandHost extends BandHost {
        mBand.setBounds(rect);

        if (!mIsOverlayShown) {
            mView.getOverlay().add(mBand);
            mRecView.getOverlay().add(mBand);
        }
    }

    @Override
    public void hideBand() {
        mView.getOverlay().remove(mBand);
        mRecView.getOverlay().remove(mBand);
    }

    @Override
    public boolean hasView(int pos) {
        return mView.findViewHolderForAdapterPosition(pos) != null;
        return mRecView.findViewHolderForAdapterPosition(pos) != null;
    }
}
 No newline at end of file
Loading