/* * Copyright (C) 2015 The Android Open Source Project * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.android.documentsui.selection; import static android.support.v4.util.Preconditions.checkArgument; 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; import android.support.v7.widget.RecyclerView.OnItemTouchListener; import android.support.v7.widget.RecyclerView.OnScrollListener; import android.util.Log; import android.view.MotionEvent; import com.android.documentsui.selection.SelectionHelper.SelectionPredicate; import com.android.documentsui.selection.SelectionHelper.StableIdProvider; import com.android.documentsui.selection.ViewAutoScroller.ScrollHost; import com.android.documentsui.selection.ViewAutoScroller.ScrollerCallbacks; import java.util.ArrayList; import java.util.List; import java.util.Set; /** * 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. * *

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 { static final boolean DEBUG = false; static final String TAG = "BandController"; private final BandHost mHost; private final StableIdProvider mStableIds; private final RecyclerView.Adapter mAdapter; private final SelectionHelper mSelectionHelper; private final SelectionPredicate mSelectionPredicate; private final BandPredicate mBandPredicate; private final ContentLock mLock; private final Runnable mViewScroller; private final GridModel.SelectionObserver mGridObserver; private final List mBandStartedListeners = new ArrayList<>(); @Nullable private Rect mBounds; @Nullable private Point mCurrentPosition; @Nullable private Point mOrigin; @Nullable private GridModel mModel; public BandSelectionHelper( BandHost host, RecyclerView.Adapter adapter, StableIdProvider stableIds, SelectionHelper selectionHelper, SelectionPredicate selectionPredicate, BandPredicate bandPredicate, ContentLock lock) { checkArgument(host != null); checkArgument(adapter != null); checkArgument(stableIds != null); checkArgument(selectionHelper != null); checkArgument(selectionPredicate != null); checkArgument(bandPredicate != null); checkArgument(lock != null); mHost = host; mStableIds = stableIds; mAdapter = adapter; mSelectionHelper = selectionHelper; mSelectionPredicate = selectionPredicate; mBandPredicate = bandPredicate; mLock = lock; mHost.addOnScrollListener( new OnScrollListener() { @Override public void onScrolled(RecyclerView recyclerView, int dx, int dy) { BandSelectionHelper.this.onScrolled(recyclerView, dx, dy); } }); mViewScroller = new ViewAutoScroller( new ScrollHost() { @Override public Point getCurrentPosition() { return mCurrentPosition; } @Override public int getViewHeight() { return mHost.getHeight(); } @Override public boolean isActive() { return BandSelectionHelper.this.isActive(); } }, host); mAdapter.registerAdapterDataObserver( new RecyclerView.AdapterDataObserver() { @Override public void onChanged() { if (isActive()) { endBandSelect(); } } @Override public void onItemRangeChanged( int startPosition, int itemCount, Object payload) { // No change in position. Ignoring. } @Override public void onItemRangeInserted(int startPosition, int itemCount) { if (isActive()) { endBandSelect(); } } @Override public void onItemRangeRemoved(int startPosition, int itemCount) { assert(startPosition >= 0); assert(itemCount > 0); // TODO: Should update grid model. } @Override public void onItemRangeMoved(int fromPosition, int toPosition, int itemCount) { throw new UnsupportedOperationException(); } }); mGridObserver = new GridModel.SelectionObserver() { @Override public void onSelectionChanged(Set updatedSelection) { mSelectionHelper.setProvisionalSelection(updatedSelection); } }; } @VisibleForTesting boolean isActive() { 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); } /** * Removes listener. No-op if listener was not previously installed. */ public void removeOnBandStartedListener(Runnable listener) { mBandStartedListeners.remove(listener); } /** * Clients must call reset when there are any material changes to the layout of items * in RecyclerView. */ public void reset() { if (!isActive()) { return; } mHost.hideBand(); mModel.stopCapturing(); mModel.onDestroy(); mModel = null; mOrigin = null; mLock.unblock(); } boolean shouldStart(MotionEvent e) { // Don't start, or extend bands on non-left clicks. if (!MotionEvents.isPrimaryButtonPressed(e)) { return false; } // TODO: Refactor to NOT have side-effects on this "should" method. // Weird things happen if we keep up band select // when touch events happen. if (isActive() && !MotionEvents.isMouseEvent(e)) { endBandSelect(); return false; } // b/30146357 && b/23793622. onInterceptTouchEvent does not dispatch events to onTouchEvent // unless the event is != ACTION_DOWN. Thus, we need to actually start band selection when // mouse moves, or else starting band selection on mouse down can cause problems as events // don't get routed correctly to onTouchEvent. return !isActive() && MotionEvents.isActionMove(e) // the initial button move via mouse-touch (ie. down press) // The adapter inserts items for UI layout purposes that aren't // associated with files. Checking against actual modelIds count // effectively ignores those UI layout items. && !mStableIds.getStableIds().isEmpty() && mBandPredicate.canInitiate(e); } public boolean shouldStop(MotionEvent e) { return isActive() && MotionEvents.isMouseEvent(e) && (MotionEvents.isActionUp(e) || MotionEvents.isActionPointerUp(e) || MotionEvents.isActionCancel(e)); } @Override public boolean onInterceptTouchEvent(RecyclerView unused, MotionEvent e) { if (shouldStart(e)) { if (!MotionEvents.isCtrlKeyPressed(e)) { mSelectionHelper.clearSelection(); } startBandSelect(MotionEvents.getOrigin(e)); return isActive(); } if (shouldStop(e)) { endBandSelect(); checkState(mModel == null); // fall through to return false, because the band eeess done! } return false; } /** * Processes a MotionEvent by starting, ending, or resizing the band select overlay. * @param input */ @Override public void onTouchEvent(RecyclerView unused, MotionEvent e) { if (shouldStop(e)) { endBandSelect(); return; } // We shouldn't get any events in this method when band select is not active, // but it turns some guests show up late to the party. // Probably happening when a re-layout is happening to the ReyclerView (ie. Pull-To-Refresh) if (!isActive()) { return; } assert MotionEvents.isActionMove(e); mCurrentPosition = MotionEvents.getOrigin(e); mModel.resizeSelection(mCurrentPosition); scrollViewIfNecessary(); resizeBand(); } @Override public void onRequestDisallowInterceptTouchEvent(boolean disallowIntercept) {} /** * Starts band select by adding the drawable to the RecyclerView's overlay. */ 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(); notifyBandStarted(); mOrigin = origin; mModel.startCapturing(mOrigin); } private void notifyBandStarted() { for (Runnable listener : mBandStartedListeners) { listener.run(); } } private void scrollViewIfNecessary() { mHost.removeCallback(mViewScroller); mViewScroller.run(); mHost.invalidateView(); } /** * Resizes the band select rectangle by using the origin and the current pointer position as * two opposite corners of the selection. */ 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); } /** * Ends band select by removing the overlay. */ private void endBandSelect() { if (DEBUG) Log.d(TAG, "Ending band select."); // 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 && 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); } mSelectionHelper.mergeProvisionalSelection(); reset(); } /** * @see RecyclerView.OnScrollListener */ private void onScrolled(RecyclerView recyclerView, int dx, int dy) { if (!isActive()) { return; } // 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; resizeBand(); } /** * Provides functionality for BandController. Exists primarily to tests that are * fully isolated from RecyclerView. */ public static abstract class BandHost extends ScrollerCallbacks { public abstract void showBand(Rect rect); public abstract void hideBand(); public abstract void addOnScrollListener(RecyclerView.OnScrollListener listener); public abstract void removeOnScrollListener(RecyclerView.OnScrollListener listener); public abstract int getHeight(); public abstract void invalidateView(); public abstract Point createAbsolutePoint(Point relativePoint); public abstract Rect getAbsoluteRectForChildViewAt(int index); public abstract int getAdapterPositionAt(int index); public abstract int getColumnCount(); public abstract int getChildCount(); public abstract int getVisibleChildCount(); /** * @return true if the item at adapter position is attached to a view. */ public abstract boolean hasView(int adapterPosition); } }