diff --git a/app/build.gradle b/app/build.gradle index b5473053e..10c604bd3 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -90,7 +90,6 @@ dependencies { implementation 'com.github.seven332:android-resource:0.1.0' implementation 'com.github.seven332:beerbelly:0.1.4' implementation 'com.github.seven332:conaco:0.1.5-eh' - implementation 'com.github.seven332:easyrecyclerview:0.1.1' implementation 'com.github.seven332:glgallery:0.1.2' implementation 'com.github.seven332:glview:0.1.0' implementation 'com.github.seven332:glview-image:0.1.0' @@ -113,6 +112,7 @@ dependencies { implementation 'com.github.NekoInverter:DrawerLayout:6ba992f8d4' implementation 'com.github.NekoInverter:Image:8fd6c1e1df' implementation 'com.github.NekoInverter.preferencex-android:preferencex-simplemenu:88f93154b2' + implementation 'fadeapp.widgets:scrollless-recyclerView:1.0.2' implementation 'org.ccil.cowan.tagsoup:tagsoup:1.2.1' implementation 'org.greenrobot:greendao:2.2.1' implementation 'org.jsoup:jsoup:1.12.2' diff --git a/app/src/main/java/com/hippo/easyrecyclerview/EasyRecyclerView.java b/app/src/main/java/com/hippo/easyrecyclerview/EasyRecyclerView.java new file mode 100644 index 000000000..bedb3ee53 --- /dev/null +++ b/app/src/main/java/com/hippo/easyrecyclerview/EasyRecyclerView.java @@ -0,0 +1,1535 @@ +/* + * Copyright (C) 2015 Hippo Seven + * + * 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.hippo.easyrecyclerview; + +import android.content.Context; +import android.graphics.Canvas; +import android.graphics.Rect; +import android.graphics.drawable.Drawable; +import android.graphics.drawable.TransitionDrawable; +import android.os.Build; +import android.os.Parcel; +import android.os.Parcelable; +import android.util.AttributeSet; +import android.util.SparseBooleanArray; +import android.util.StateSet; +import android.view.ActionMode; +import android.view.HapticFeedbackConstants; +import android.view.Menu; +import android.view.MenuItem; +import android.view.MotionEvent; +import android.view.SoundEffectConstants; +import android.view.View; +import android.view.ViewConfiguration; +import android.view.accessibility.AccessibilityEvent; +import android.widget.Checkable; + +import androidx.annotation.NonNull; +import androidx.collection.LongSparseArray; +import androidx.core.view.ViewCompat; +import androidx.recyclerview.widget.RecyclerView; + +import com.hippo.ehviewer.Settings; +import com.hippo.hotspot.Hotspot; +import com.hippo.yorozuya.MathUtils; +import com.hippo.yorozuya.NumberUtils; +import com.hippo.yorozuya.ViewUtils; + +import java.util.Set; + +import fadeapp.widgets.ScrollLessRecyclerView; + +/** + * Add setOnItemClickListener, setOnItemLongClickListener and setChoiceMode for + * RecyclerView + */ +// Get some code from twoway-view and AbsListView. +public class EasyRecyclerView extends ScrollLessRecyclerView { + + /** + * Represents an invalid position. All valid positions are in the range 0 to 1 less than the + * number of items in the current adapter. + */ + public static final int INVALID_POSITION = -1; + + /** + * Normal list that does not indicate choices + */ + public static final int CHOICE_MODE_NONE = 0; + + /** + * The list allows up to one choice + */ + public static final int CHOICE_MODE_SINGLE = 1; + + /** + * The list allows multiple choices + */ + public static final int CHOICE_MODE_MULTIPLE = 2; + + /** + * The list allows multiple choices in a modal selection mode + */ + public static final int CHOICE_MODE_MULTIPLE_MODAL = 3; + + /** + * The list allows multiple choices in custom action + */ + public static final int CHOICE_MODE_MULTIPLE_CUSTOM = 4; + + /** + * Controls if/how the user may choose/check items in the list + */ + private int mChoiceMode = CHOICE_MODE_NONE; + + /** + * Controls CHOICE_MODE_MULTIPLE_MODAL. null when inactive. + */ + private ActionMode mChoiceActionMode; + + /** + * Wrapper for the multiple choice mode callback; AbsListView needs to perform + * a few extra actions around what application code does. + */ + MultiChoiceModeWrapper mMultiChoiceModeCallback; + + /** + * Listener for custom multiple choices + */ + private CustomChoiceListener mCustomChoiceListener; + + private boolean mCustomChoice = false; + + /** + * A lock, avoid OutOfCustomChoiceMode when doing OutOfCustomChoiceMode + */ + private boolean mOutOfCustomChoiceModing = false; + + private SparseBooleanArray mTempCheckStates; + + /** + * Running count of how many items are currently checked + */ + private int mCheckedItemCount; + + /** + * Running state of which positions are currently checked + */ + private SparseBooleanArray mCheckStates; + + /** + * Running state of which IDs are currently checked. + * If there is a value for a given key, the checked state for that ID is true + * and the value holds the last known position in the adapter for that id. + */ + private LongSparseArray mCheckedIdStates; + + private Adapter mAdapter; + + // For click and long click + private boolean mPrePressed = false; + private boolean mHasPerformedLongPress = false; + private View mMotionView; + private int mMotionPosition; + private CheckForTap mPendingCheckForTap; + private CheckForLongPress mPendingCheckForLongPress; + private UnsetPressedState mUnsetPressedState; + private PerformClick mPerformClick; + private final float[] mTmpPoint = new float[2]; + private int mTouchSlop = -1; + private float mStartX; + private float mStartY; + private OnItemClickListener mOnItemClickListener; + private OnItemLongClickListener mOnItemLongClickListener; + + /** + * Indicates whether the list selector should be drawn on top of the children or behind + */ + boolean mDrawSelectorOnTop = false; + /** + * The drawable used to draw the selector + */ + private Drawable mSelector; + /** + * The current position of the selector in the list. + */ + private int mSelectorPosition = INVALID_POSITION; + /** + * Defines the selector's location and dimension at drawing time + */ + private final Rect mSelectorRect = new Rect(); + /** + * The selection's left padding + */ + int mSelectionLeftPadding = 0; + /** + * The selection's top padding + */ + int mSelectionTopPadding = 0; + /** + * The selection's right padding + */ + int mSelectionRightPadding = 0; + /** + * The selection's bottom padding + */ + int mSelectionBottomPadding = 0; + + private OnDrawSelectorListener mOnDrawSelectorListener; + + private boolean mClipToPadding = false; + + private boolean mTouchFromScrolling; + + public EasyRecyclerView(Context context) { + super(context); + setEnableScroll(!Settings.getEInkMode()); + } + + public EasyRecyclerView(Context context, AttributeSet attrs) { + super(context, attrs); + setEnableScroll(!Settings.getEInkMode()); + } + + public EasyRecyclerView(Context context, AttributeSet attrs, int defStyle) { + super(context, attrs, defStyle); + setEnableScroll(!Settings.getEInkMode()); + } + + @Override + public void setClipToPadding(boolean clipToPadding) { + super.setClipToPadding(clipToPadding); + + mClipToPadding = clipToPadding; + } + + /** + * {@inheritDoc} + */ + @Override + public void setAdapter(RecyclerView.Adapter adapter) { + + super.setAdapter(adapter); + + mAdapter = adapter; + if (adapter != null) { + if (mChoiceMode != CHOICE_MODE_NONE && adapter.hasStableIds() && + mCheckedIdStates == null) { + mCheckedIdStates = new LongSparseArray<>(); + } + } + + if (mCheckStates != null) { + mCheckStates.clear(); + } + + if (mCheckedIdStates != null) { + mCheckedIdStates.clear(); + } + } + + void positionSelector(int position, View sel) { + positionSelector(position, sel, false, -1, -1); + } + + private void positionSelector(int position, View sel, boolean manageHotspot, float x, float y) { + final boolean positionChanged = position != mSelectorPosition; + if (position != INVALID_POSITION) { + mSelectorPosition = position; + } + + final Rect selectorRect = mSelectorRect; + selectorRect.set(0, 0, sel.getWidth(), sel.getHeight()); + + // Adjust for selection padding. + selectorRect.left -= mSelectionLeftPadding; + selectorRect.top -= mSelectionTopPadding; + selectorRect.right += mSelectionRightPadding; + selectorRect.bottom += mSelectionBottomPadding; + + // Update the selector drawable. + final Drawable selector = mSelector; + if (selector != null) { + if (positionChanged) { + // Wipe out the current selector state so that we can start + // over in the new position with a fresh state. + selector.setVisible(false, false); + selector.setState(StateSet.NOTHING); + } + selector.setBounds(selectorRect); + if (positionChanged) { + if (getVisibility() == VISIBLE) { + selector.setVisible(true, false); + } + updateSelectorState(); + } + if (manageHotspot) { + setSelectorHotspot(sel, x, y); + } + } + } + + private void setSelectorHotspot(View sel, float x, float y) { + Hotspot.setHotspot(mSelector, x - sel.getLeft(), y - sel.getTop()); + } + + @Override + protected void dispatchDraw(@NonNull Canvas canvas) { + boolean clipToPadding = mClipToPadding; + int saveCount = 0; + if (clipToPadding) { + saveCount = canvas.save(); + final int scrollX = getScrollX(); + final int scrollY = getScrollY(); + canvas.clipRect(scrollX + getPaddingLeft(), scrollY + getPaddingTop(), + scrollX + getRight() - getLeft() - getPaddingRight(), + scrollY + getBottom() - getTop() - getPaddingBottom()); + } + + // Check mMotionView + if (mMotionView != null && !ViewCompat.isAttachedToWindow(mMotionView)) { + mMotionView = null; + } + + boolean drawSelector = mMotionView != null && + mSelectorPosition >= 0 && mSelectorPosition < mAdapter.getItemCount() && + (mOnDrawSelectorListener == null || mOnDrawSelectorListener.beforeDrawSelector(mSelectorPosition)); + final boolean drawSelectorOnTop = mDrawSelectorOnTop; + + if (drawSelector && !drawSelectorOnTop) { + int saveCount2 = canvas.save(); + canvas.translate(mMotionView.getLeft(), mMotionView.getTop()); + drawSelector(canvas); + canvas.restoreToCount(saveCount2); + } + + super.dispatchDraw(canvas); + + if (drawSelector && drawSelectorOnTop) { + int saveCount2 = canvas.save(); + canvas.translate(mMotionView.getLeft(), mMotionView.getTop()); + drawSelector(canvas); + canvas.restoreToCount(saveCount2); + } + + if (clipToPadding) { + canvas.restoreToCount(saveCount); + } + } + + /** + * Indicates whether this view is in a state where the selector should be drawn. This will + * happen if we have focus but are not in touch mode, or we are in the middle of displaying + * the pressed state for an item. + * + * @return True if the selector should be shown + */ + boolean shouldShowSelector() { + return (isFocused() && !isInTouchMode()) || isPressed(); + } + + private void drawSelector(Canvas canvas) { + final Drawable selector = mSelector; + if (selector != null && !mSelectorRect.isEmpty()) { + selector.setBounds(mSelectorRect); + selector.draw(canvas); + } + } + + public void setOnDrawSelectorListener(OnDrawSelectorListener listener) { + mOnDrawSelectorListener = listener; + } + + /** + * Controls whether the selection highlight drawable should be drawn on top of the item or + * behind it. + * + * @param onTop If true, the selector will be drawn on the item it is highlighting. The default + * is false. + */ + public void setDrawSelectorOnTop(boolean onTop) { + mDrawSelectorOnTop = onTop; + } + + public void setSelector(Drawable sel) { + if (mSelector != null) { + mSelector.setCallback(null); + unscheduleDrawable(mSelector); + } + mSelector = sel; + Rect padding = new Rect(); + sel.getPadding(padding); + mSelectionLeftPadding = padding.left; + mSelectionTopPadding = padding.top; + mSelectionRightPadding = padding.right; + mSelectionBottomPadding = padding.bottom; + sel.setCallback(this); + updateSelectorState(); + } + + /** + * Returns the selector {@link android.graphics.drawable.Drawable} that is used to draw the + * selection in the list. + * + * @return the drawable used to display the selector + */ + public Drawable getSelector() { + return mSelector; + } + + void updateSelectorState() { + if (mSelector != null) { + if (shouldShowSelector()) { + mSelector.setState(getDrawableState()); + } else { + mSelector.setState(StateSet.NOTHING); + } + } + } + + @Override + protected void drawableStateChanged() { + super.drawableStateChanged(); + updateSelectorState(); + } + + @Override + protected void dispatchSetPressed(boolean pressed) { + // Don't dispatch setPressed to our children. We call setPressed on ourselves to + // get the selector in the right state, but we don't want to press each child. + } + + @Override + public void dispatchDrawableHotspotChanged(float x, float y) { + // Don't dispatch hotspot changes to children. We'll manually handle + // calling drawableHotspotChanged on the correct child. + } + + @Override + public boolean verifyDrawable(Drawable dr) { + return mSelector == dr || super.verifyDrawable(dr); + } + + @Override + public void jumpDrawablesToCurrentState() { + super.jumpDrawablesToCurrentState(); + if (mSelector != null) { + mSelector.jumpToCurrentState(); + } + } + + @Override + public void onChildAttachedToWindow(View child) { + super.onChildAttachedToWindow(child); + + if (mCheckStates != null) { + int position = getChildAdapterPosition(child); + if (position >= 0) { + setViewChecked(child, mCheckStates.get(position)); + } + } + } + + /** + * Returns the number of items currently selected. This will only be valid + * if the choice mode is not {@link #CHOICE_MODE_NONE} (default). + * + *

To determine the specific items that are currently selected, use one of + * the getChecked* methods. + * + * @return The number of items currently selected + * @see #getCheckedItemPosition() + * @see #getCheckedItemPositions() + * @see #getCheckedItemIds() + */ + public int getCheckedItemCount() { + return mCheckedItemCount; + } + + /** + * Returns the checked state of the specified position. The result is only + * valid if the choice mode has been set to {@link #CHOICE_MODE_SINGLE} + * or {@link #CHOICE_MODE_MULTIPLE}. + * + * @param position The item whose checked state to return + * @return The item's checked state or false if choice mode + * is invalid + * @see #setChoiceMode(int) + */ + public boolean isItemChecked(int position) { + //noinspection SimplifiableIfStatement + if (mChoiceMode != CHOICE_MODE_NONE && mCheckStates != null) { + return mCheckStates.get(position); + } + + return false; + } + + /** + * Returns the currently checked item. The result is only valid if the choice + * mode has been set to {@link #CHOICE_MODE_SINGLE}. + * + * @return The position of the currently checked item or + * {@link #INVALID_POSITION} if nothing is selected + * @see #setChoiceMode(int) + */ + public int getCheckedItemPosition() { + if (mChoiceMode == CHOICE_MODE_SINGLE && mCheckStates != null && mCheckStates.size() == 1) { + return mCheckStates.keyAt(0); + } + + return INVALID_POSITION; + } + + /** + * Returns the set of checked items in the list. The result is only valid if + * the choice mode has not been set to {@link #CHOICE_MODE_NONE}. + * + * @return A SparseBooleanArray which will return true for each call to + * get(int position) where position is a checked position in the + * list and false otherwise, or null if the choice + * mode is set to {@link #CHOICE_MODE_NONE}. + */ + public SparseBooleanArray getCheckedItemPositions() { + if (mChoiceMode != CHOICE_MODE_NONE) { + return mCheckStates; + } + return null; + } + + /** + * Returns the set of checked items ids. The result is only valid if the + * choice mode has not been set to {@link #CHOICE_MODE_NONE} and the adapter + * has stable IDs. ({@link android.widget.ListAdapter#hasStableIds()} == {@code true}) + * + * @return A new array which contains the id of each checked item in the + * list. + */ + public long[] getCheckedItemIds() { + if (mChoiceMode == CHOICE_MODE_NONE || mCheckedIdStates == null || mAdapter == null) { + return new long[0]; + } + + final LongSparseArray idStates = mCheckedIdStates; + final int count = idStates.size(); + final long[] ids = new long[count]; + + for (int i = 0; i < count; i++) { + ids[i] = idStates.keyAt(i); + } + + return ids; + } + + public boolean isInCustomChoice() { + return mCustomChoice; + } + + public void intoCustomChoiceMode() { + if (mChoiceMode == CHOICE_MODE_MULTIPLE_CUSTOM && !mCustomChoice) { + mCustomChoice = true; + + mCustomChoiceListener.onIntoCustomChoice(this); + } + } + + public void outOfCustomChoiceMode() { + if (mChoiceMode == CHOICE_MODE_MULTIPLE_CUSTOM && mCustomChoice && !mOutOfCustomChoiceModing) { + mOutOfCustomChoiceModing = true; + + // Copy mCheckStates + mTempCheckStates.clear(); + for (int i = 0, n = mCheckStates.size(); i < n; i++) { + mTempCheckStates.put(mCheckStates.keyAt(i), mCheckStates.valueAt(i)); + } + // Uncheck remain checked items + for (int i = 0, n = mTempCheckStates.size(); i < n; i++) { + if (mTempCheckStates.valueAt(i)) { + setItemChecked(mTempCheckStates.keyAt(i), false); + } + } + + mCustomChoice = false; + mCustomChoiceListener.onOutOfCustomChoice(this); + + mOutOfCustomChoiceModing = false; + } + } + + /** + * Clear any choices previously set + */ + private void clearChoices() { + if (mCheckStates != null) { + mCheckStates.clear(); + } + if (mCheckedIdStates != null) { + mCheckedIdStates.clear(); + } + mCheckedItemCount = 0; + updateOnScreenCheckedViews(); + } + + /** + * Sets all items checked. + */ + public void checkAll() { + if (mChoiceMode == CHOICE_MODE_NONE || mChoiceMode == CHOICE_MODE_SINGLE) { + return; + } + + // Check is intoCheckMode + if (mChoiceMode == CHOICE_MODE_MULTIPLE_CUSTOM && !mCustomChoice) { + throw new IllegalStateException("Call intoCheckMode first"); + } + + // Start selection mode if needed. We don't need to if we're unchecking something. + if (mChoiceMode == CHOICE_MODE_MULTIPLE_MODAL && mChoiceActionMode == null) { + if (mMultiChoiceModeCallback == null || + !mMultiChoiceModeCallback.hasWrappedCallback()) { + throw new IllegalStateException("EasyRecyclerView: attempted to start selection mode " + + "for CHOICE_MODE_MULTIPLE_MODAL but no choice mode callback was " + + "supplied. Call setMultiChoiceModeListener to set a callback."); + } + mChoiceActionMode = startActionMode(mMultiChoiceModeCallback); + } + + for (int i = 0, n = mAdapter.getItemCount(); i < n; i++) { + boolean oldValue = mCheckStates.get(i); + mCheckStates.put(i, true); + if (mCheckedIdStates != null && mAdapter.hasStableIds()) { + mCheckedIdStates.put(mAdapter.getItemId(i), i); + } + if (!oldValue) { + mCheckedItemCount++; + } + if (mChoiceActionMode != null) { + final long id = mAdapter.getItemId(i); + mMultiChoiceModeCallback.onItemCheckedStateChanged(mChoiceActionMode, i, id, true); + } + if (mChoiceMode == CHOICE_MODE_MULTIPLE_CUSTOM) { + final long id = mAdapter.getItemId(i); + mCustomChoiceListener.onItemCheckedStateChanged(this, i, id, true); + } + } + + updateOnScreenCheckedViews(); + } + + public void toggleItemChecked(int position) { + if (mCheckStates != null) { + setItemChecked(position, !mCheckStates.get(position)); + } + } + + /** + * Sets the checked state of the specified position. The is only valid if + * the choice mode has been set to {@link #CHOICE_MODE_SINGLE} or + * {@link #CHOICE_MODE_MULTIPLE}. + * + * @param position The item whose checked state is to be checked + * @param value The new checked state for the item + */ + public void setItemChecked(int position, boolean value) { + if (mChoiceMode == CHOICE_MODE_NONE) { + return; + } + + // Check is intoCheckMode + if (mChoiceMode == CHOICE_MODE_MULTIPLE_CUSTOM && !mCustomChoice) { + throw new IllegalStateException("Call intoCheckMode first"); + } + + // Start selection mode if needed. We don't need to if we're unchecking something. + if (value && mChoiceMode == CHOICE_MODE_MULTIPLE_MODAL && mChoiceActionMode == null) { + if (mMultiChoiceModeCallback == null || + !mMultiChoiceModeCallback.hasWrappedCallback()) { + throw new IllegalStateException("EasyRecyclerView: attempted to start selection mode " + + "for CHOICE_MODE_MULTIPLE_MODAL but no choice mode callback was " + + "supplied. Call setMultiChoiceModeListener to set a callback."); + } + mChoiceActionMode = startActionMode(mMultiChoiceModeCallback); + } + + if (mChoiceMode == CHOICE_MODE_MULTIPLE || mChoiceMode == CHOICE_MODE_MULTIPLE_MODAL || mChoiceMode == CHOICE_MODE_MULTIPLE_CUSTOM) { + boolean oldValue = mCheckStates.get(position); + mCheckStates.put(position, value); + if (mCheckedIdStates != null && mAdapter.hasStableIds()) { + if (value) { + mCheckedIdStates.put(mAdapter.getItemId(position), position); + } else { + mCheckedIdStates.delete(mAdapter.getItemId(position)); + } + } + if (oldValue != value) { + if (value) { + mCheckedItemCount++; + } else { + mCheckedItemCount--; + } + } + if (mChoiceActionMode != null) { + final long id = mAdapter.getItemId(position); + mMultiChoiceModeCallback.onItemCheckedStateChanged(mChoiceActionMode, + position, id, value); + } + if (mChoiceMode == CHOICE_MODE_MULTIPLE_CUSTOM) { + final long id = mAdapter.getItemId(position); + mCustomChoiceListener.onItemCheckedStateChanged(this, position, id, value); + } + } else { + boolean updateIds = mCheckedIdStates != null && mAdapter.hasStableIds(); + // Clear all values if we're checking something, or unchecking the currently + // selected item + if (value || isItemChecked(position)) { + mCheckStates.clear(); + if (updateIds) { + mCheckedIdStates.clear(); + } + } + // this may end up selecting the value we just cleared but this way + // we ensure length of mCheckStates is 1, a fact getCheckedItemPosition relies on + if (value) { + mCheckStates.put(position, true); + if (updateIds) { + mCheckedIdStates.put(mAdapter.getItemId(position), position); + } + mCheckedItemCount = 1; + } else if (mCheckStates.size() == 0 || !mCheckStates.valueAt(0)) { + mCheckedItemCount = 0; + } + } + + updateOnScreenCheckedViews(); + } + + /** + * @return The current choice mode + * @see #setChoiceMode(int) + */ + public int getChoiceMode() { + return mChoiceMode; + } + + /** + * Defines the choice behavior for the List. By default, Lists do not have any choice behavior + * ({@link #CHOICE_MODE_NONE}). By setting the choiceMode to {@link #CHOICE_MODE_SINGLE}, the + * List allows up to one item to be in a chosen state. By setting the choiceMode to + * {@link #CHOICE_MODE_MULTIPLE}, the list allows any number of items to be chosen. + * + * @param choiceMode One of {@link #CHOICE_MODE_NONE}, {@link #CHOICE_MODE_SINGLE}, or + * {@link #CHOICE_MODE_MULTIPLE} + */ + public void setChoiceMode(int choiceMode) { + mChoiceMode = choiceMode; + if (mChoiceActionMode != null) { + mChoiceActionMode.finish(); + mChoiceActionMode = null; + } + if (mChoiceMode != CHOICE_MODE_NONE) { + if (mCheckStates == null) { + mCheckStates = new SparseBooleanArray(0); + } + if (mCheckedIdStates == null && mAdapter != null && mAdapter.hasStableIds()) { + mCheckedIdStates = new LongSparseArray<>(0); + } + // Modal multi-choice mode only has choices when the mode is active. Clear them. + if (mChoiceMode == CHOICE_MODE_MULTIPLE_MODAL) { + clearChoices(); + setLongClickable(true); + } else if (mChoiceMode == CHOICE_MODE_MULTIPLE_CUSTOM) { + if (mTempCheckStates == null) { + mTempCheckStates = new SparseBooleanArray(0); + } + clearChoices(); + } + } + } + + /** + * Set a {@link MultiChoiceModeListener} that will manage the lifecycle of the + * selection {@link android.view.ActionMode}. Only used when the choice mode is set to + * {@link #CHOICE_MODE_MULTIPLE_MODAL}. + * + * @param listener Listener that will manage the selection mode + * @see #setChoiceMode(int) + */ + public void setMultiChoiceModeListener(MultiChoiceModeListener listener) { + if (mMultiChoiceModeCallback == null) { + mMultiChoiceModeCallback = new MultiChoiceModeWrapper(); + } + mMultiChoiceModeCallback.setWrapped(listener); + } + + /** + * @param listener + */ + public void setCustomCheckedListener(CustomChoiceListener listener) { + mCustomChoiceListener = listener; + } + + /** + * Perform a quick, in-place update of the checked or activated state + * on all visible item views. This should only be called when a valid + * choice mode is active. + */ + private void updateOnScreenCheckedViews() { + final int count = getChildCount(); + for (int i = 0; i < count; i++) { + final View child = getChildAt(i); + final int position = getChildAdapterPosition(child); + setViewChecked(child, mCheckStates.get(position)); + } + } + + public static void setViewChecked(View view, boolean checked) { + final boolean useActivated = Build.VERSION.SDK_INT >= Build.VERSION_CODES.HONEYCOMB; + if (view instanceof Checkable) { + ((Checkable) view).setChecked(checked); + } else if (useActivated) { + view.setActivated(checked); + } + } + + public float getTouchStartX() { + return mStartX; + } + + public float getTouchStartY() { + return mStartY; + } + + @Override + public boolean onInterceptTouchEvent(MotionEvent ev) { + if (ev.getActionMasked() == MotionEvent.ACTION_DOWN) { + mTouchFromScrolling = SCROLL_STATE_SETTLING == getScrollState(); + } + + return super.onInterceptTouchEvent(ev); + } + + @Override + public boolean onTouchEvent(MotionEvent ev) { + if (!isEnabled()) { + // A disabled view that is clickable still consumes the touch + // events, it just doesn't respond to them. + return isClickable() || isLongClickable(); + } + + switch (ev.getActionMasked()) { + case MotionEvent.ACTION_DOWN: { + onTouchDown(ev); + break; + } + + case MotionEvent.ACTION_MOVE: { + onTouchMove(ev); + break; + } + + case MotionEvent.ACTION_UP: { + onTouchUp(ev); + break; + } + + case MotionEvent.ACTION_CANCEL: { + onTouchCancel(); + break; + } + } + + super.onTouchEvent(ev); + + return true; + } + + private void onTouchDown(MotionEvent ev) { + if (isLayoutFrozen() || mTouchFromScrolling) { + return; + } + + final float x = ev.getX(); + final float y = ev.getY(); + mStartX = x; + mStartY = y; + + mMotionView = findChildViewUnder(x, y); + mMotionPosition = getChildAdapterPosition(mMotionView); + + if (mMotionView != null && mMotionPosition >= 0 && mMotionView.isEnabled()) { + mHasPerformedLongPress = false; + + mPrePressed = true; + if (mPendingCheckForTap == null) { + mPendingCheckForTap = new CheckForTap(); + } + mPendingCheckForTap.x = x; + mPendingCheckForTap.y = y; + mPendingCheckForTap.v = mMotionView; + mPendingCheckForTap.p = mMotionPosition; + postDelayed(mPendingCheckForTap, ViewConfiguration.getTapTimeout()); + + } else { + mMotionView = null; + } + } + + private void onTouchMove(MotionEvent ev) { + if (isLayoutFrozen() || mTouchFromScrolling) { + return; + } + + if (mTouchSlop == -1) { + mTouchSlop = ViewConfiguration.get(getContext()).getScaledTouchSlop(); + } + + if (mMotionView != null && mMotionPosition >= 0 && + !MathUtils.near(mStartX, mStartY, ev.getX(), ev.getY(), mTouchSlop)) { + removeTapCallback(); + if (mMotionView.isPressed()) { + // Remove any future long press/tap checks + removeLongPressCallback(); + mMotionView.setPressed(false); + setPressed(false); + } + updateSelectorState(); + } + } + + private void onTouchUp(MotionEvent ev) { + if (isLayoutFrozen() || mTouchFromScrolling) { + return; + } + + View motionView = mMotionView; + int motionPosition = mMotionPosition; + + if (motionView != null && motionPosition >= 0) { + final float x = ev.getX(); + final float y = ev.getY(); + + if (motionView.isPressed() || mPrePressed) { + // The button is being released before we actually + // showed it as pressed. Make it show the pressed + // state now (before scheduling the click) to ensure + // the user sees it. + final float[] point = mTmpPoint; + point[0] = x; + point[1] = y; + ViewUtils.transformPointToViewLocal(point, EasyRecyclerView.this, motionView); + setPressed(motionView, true, point[0], point[1]); + setPressed(true); + positionSelector(mMotionPosition, motionView); + if (mSelector != null) { + Drawable d = mSelector.getCurrent(); + if (d instanceof TransitionDrawable) { + ((TransitionDrawable) d).resetTransition(); + } + setSelectorHotspot(motionView, x, y); + } + + if (!mHasPerformedLongPress) { + // This is a tap, so remove the longpress check + removeLongPressCallback(); + + // Use a Runnable and post this rather than calling + // performClick directly. This lets other visual state + // of the view update before click actions start. + if (mPerformClick == null) { + mPerformClick = new PerformClick(); + } + mPerformClick.v = motionView; + mPerformClick.p = motionPosition; + mPerformClick.rememberWindowAttachCount(); + if (!post(mPerformClick)) { + mPerformClick.run(); + } + } + + if (mUnsetPressedState == null) { + mUnsetPressedState = new UnsetPressedState(); + } + + mUnsetPressedState.v = motionView; + if (mPrePressed) { + postDelayed(mUnsetPressedState, ViewConfiguration.getPressedStateDuration()); + } else if (!post(mUnsetPressedState)) { + // If the post failed, unpress right now + mUnsetPressedState.run(); + } + + removeTapCallback(); + } + + updateSelectorState(); + } + } + + private void onTouchCancel() { + if (isLayoutFrozen() || mTouchFromScrolling) { + return; + } + + if (mMotionView != null && mMotionPosition >= 0) { + mMotionView.setPressed(false); + setPressed(false); + removeTapCallback(); + removeLongPressCallback(); + } + } + + private void removeTapCallback() { + if (mPendingCheckForTap != null) { + mPrePressed = false; + removeCallbacks(mPendingCheckForTap); + mPendingCheckForTap.v = null; + } + } + + private void removeLongPressCallback() { + if (mPendingCheckForLongPress != null) { + removeCallbacks(mPendingCheckForLongPress); + mPendingCheckForLongPress.v = null; + } + } + + private void setPressed(View v, boolean pressed, float x, float y) { + v.setPressed(pressed); + if (pressed && Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { + v.drawableHotspotChanged(x, y); + } + } + + private boolean performItemClick(View view, int position, long id) { + boolean handled = false; + boolean dispatchItemClick = true; + + if (mChoiceMode != CHOICE_MODE_NONE && mChoiceMode != CHOICE_MODE_MULTIPLE_CUSTOM) { + handled = true; + boolean checkedStateChanged = false; + + if (mChoiceMode == CHOICE_MODE_MULTIPLE || + (mChoiceMode == CHOICE_MODE_MULTIPLE_MODAL && mChoiceActionMode != null)) { + boolean checked = !mCheckStates.get(position, false); + mCheckStates.put(position, checked); + if (mCheckedIdStates != null && mAdapter.hasStableIds()) { + if (checked) { + mCheckedIdStates.put(mAdapter.getItemId(position), position); + } else { + mCheckedIdStates.delete(mAdapter.getItemId(position)); + } + } + if (checked) { + mCheckedItemCount++; + } else { + mCheckedItemCount--; + } + if (mChoiceActionMode != null) { + mMultiChoiceModeCallback.onItemCheckedStateChanged(mChoiceActionMode, + position, id, checked); + dispatchItemClick = false; + } + checkedStateChanged = true; + } else if (mChoiceMode == CHOICE_MODE_SINGLE) { + boolean checked = !mCheckStates.get(position, false); + if (checked) { + mCheckStates.clear(); + mCheckStates.put(position, true); + if (mCheckedIdStates != null && mAdapter.hasStableIds()) { + mCheckedIdStates.clear(); + mCheckedIdStates.put(mAdapter.getItemId(position), position); + } + mCheckedItemCount = 1; + } else if (mCheckStates.size() == 0 || !mCheckStates.valueAt(0)) { + mCheckedItemCount = 0; + } + checkedStateChanged = true; + } + + if (checkedStateChanged) { + updateOnScreenCheckedViews(); + } + } + + if (dispatchItemClick) { + if (mOnItemClickListener != null) { + playSoundEffect(SoundEffectConstants.CLICK); + mOnItemClickListener.onItemClick(this, view, position, id); + if (view != null) { + view.sendAccessibilityEvent(AccessibilityEvent.TYPE_VIEW_CLICKED); + } + handled = true; + } + } + + return handled; + } + + private boolean performItemLongPress(final View child, + final int longPressPosition, final long longPressId) { + // CHOICE_MODE_MULTIPLE_MODAL takes over long press. + if (mChoiceMode == CHOICE_MODE_MULTIPLE_MODAL) { + if (mChoiceActionMode == null && + (mChoiceActionMode = startActionMode(mMultiChoiceModeCallback)) != null) { + setItemChecked(longPressPosition, true); + performHapticFeedback(HapticFeedbackConstants.LONG_PRESS); + } + return true; + } + + boolean handled = false; + if (mOnItemLongClickListener != null) { + handled = mOnItemLongClickListener.onItemLongClick(this, child, + longPressPosition, longPressId); + } + if (handled) { + performHapticFeedback(HapticFeedbackConstants.LONG_PRESS); + } + return handled; + } + + /** + * Register a callback to be invoked when an item in this AdapterView has + * been clicked. + * + * @param listener The callback that will be invoked. + */ + public void setOnItemClickListener(OnItemClickListener listener) { + mOnItemClickListener = listener; + } + + + public void setOnItemLongClickListener(OnItemLongClickListener listener) { + if (!isLongClickable()) { + setLongClickable(true); + } + mOnItemLongClickListener = listener; + } + + /** + * This saved state class is a Parcelable and should not extend + * {@link android.view.View.BaseSavedState} nor {@link android.view.AbsSavedState} + * because its super class AbsSavedState's constructor + * currently passes null + * as a class loader to read its superstate from Parcelable. + * This causes {@link android.os.BadParcelableException} when restoring saved states. + *

+ * The super class "RecyclerView" is a part of the support library, + * and restoring its saved state requires the class loader that loaded the RecyclerView. + * It seems that the class loader is not required when restoring from RecyclerView itself, + * but it is required when restoring from RecyclerView's subclasses. + */ + static class SavedState implements Parcelable { + + public static final SavedState EMPTY_STATE = new SavedState() { + }; + + int choiceMode; + boolean customChoice; + int checkedItemCount; + SparseBooleanArray checkState; + LongSparseArray checkIdState; + + // This keeps the parent(RecyclerView)'s state + Parcelable mSuperState; + + SavedState() { + mSuperState = null; + } + + /** + * Constructor called from {@link #onSaveInstanceState()} + */ + SavedState(Parcelable superState) { + mSuperState = superState != EMPTY_STATE ? superState : null; + } + + /** + * Constructor called from {@link #CREATOR} + */ + private SavedState(Parcel in) { + // Parcel 'in' has its parent(RecyclerView)'s saved state. + // To restore it, class loader that loaded RecyclerView is required. + Parcelable superState = in.readParcelable(RecyclerView.class.getClassLoader()); + mSuperState = superState != null ? superState : EMPTY_STATE; + + choiceMode = in.readInt(); + customChoice = NumberUtils.int2boolean(in.readInt()); + checkedItemCount = in.readInt(); + checkState = in.readSparseBooleanArray(); + final int N = in.readInt(); + if (N > 0) { + checkIdState = new LongSparseArray<>(); + for (int i = 0; i < N; i++) { + final long key = in.readLong(); + final int value = in.readInt(); + checkIdState.put(key, value); + } + } + } + + @Override + public int describeContents() { + return 0; + } + + @Override + public void writeToParcel(@NonNull Parcel out, int flags) { + out.writeParcelable(mSuperState, flags); + + out.writeInt(choiceMode); + out.writeInt(NumberUtils.boolean2int(customChoice)); + out.writeInt(checkedItemCount); + out.writeSparseBooleanArray(checkState); + final int N = checkIdState != null ? checkIdState.size() : 0; + out.writeInt(N); + for (int i = 0; i < N; i++) { + out.writeLong(checkIdState.keyAt(i)); + out.writeInt(checkIdState.valueAt(i)); + } + } + + public Parcelable getSuperState() { + return mSuperState; + } + + public static final Parcelable.Creator CREATOR + = new Parcelable.Creator() { + @Override + public SavedState createFromParcel(Parcel in) { + return new SavedState(in); + } + + @Override + public SavedState[] newArray(int size) { + return new SavedState[size]; + } + }; + } + + @Override + public Parcelable onSaveInstanceState() { + final SavedState ss = new SavedState(super.onSaveInstanceState()); + + ss.choiceMode = mChoiceMode; + ss.customChoice = mCustomChoice; + ss.checkedItemCount = mCheckedItemCount; + ss.checkState = mCheckStates; + ss.checkIdState = mCheckedIdStates; + + return ss; + } + + @Override + public void onRestoreInstanceState(Parcelable state) { + SavedState ss = (SavedState) state; + super.onRestoreInstanceState(ss.getSuperState()); + + setChoiceMode(ss.choiceMode); + mCustomChoice = ss.customChoice; + mCheckedItemCount = ss.checkedItemCount; + if (ss.checkState != null) { + mCheckStates = ss.checkState; + } + if (ss.checkIdState != null) { + mCheckedIdStates = ss.checkIdState; + } + + if (mChoiceMode == CHOICE_MODE_MULTIPLE_MODAL && mCheckedItemCount > 0) { + mChoiceActionMode = startActionMode(mMultiChoiceModeCallback); + } + updateOnScreenCheckedViews(); + } + + private View findViewForAdapterPosition(int position) { + ViewHolder holder = findViewHolderForAdapterPosition(position); + if (holder != null) { + return holder.itemView; + } else { + return null; + } + } + + private final class CheckForTap implements Runnable { + float x; + float y; + View v; + int p; + + @Override + public void run() { + View view = v; + int position = p; + if (view != null && position >= 0 && view == findViewForAdapterPosition(position)) { + mPrePressed = false; + final float[] point = mTmpPoint; + point[0] = x; + point[1] = y; + ViewUtils.transformPointToViewLocal(point, EasyRecyclerView.this, view); + setPressed(v, true, point[0], point[1]); + setPressed(true); + positionSelector(mMotionPosition, v); + refreshDrawableState(); + + final int longPressTimeout = ViewConfiguration.getLongPressTimeout(); + final boolean longClickable = isLongClickable(); + + if (mSelector != null) { + final Drawable d = mSelector.getCurrent(); + if (d != null && d instanceof TransitionDrawable) { + if (longClickable) { + ((TransitionDrawable) d).startTransition(longPressTimeout); + } else { + ((TransitionDrawable) d).resetTransition(); + } + } + setSelectorHotspot(v, x, y); + } + + if (longClickable) { + mHasPerformedLongPress = false; + + if (mPendingCheckForLongPress == null) { + mPendingCheckForLongPress = new CheckForLongPress(); + } + mPendingCheckForLongPress.rememberWindowAttachCount(); + mPendingCheckForLongPress.v = view; + mPendingCheckForLongPress.p = position; + postDelayed(mPendingCheckForLongPress, longPressTimeout - + ViewConfiguration.getTapTimeout()); + } + + } + + // Release + v = null; + } + } + + + private class CheckForLongPress extends WindowRunnable implements Runnable { + View v; + int p; + + @Override + public void run() { + View view = v; + int position = p; + if (view != null && position >= 0 && view == findViewForAdapterPosition(position) && + view.isPressed() && view.getParent() != null) { + boolean handled = false; + + if (sameWindow()) { + handled = performItemLongPress(view, position, getAdapter().getItemId(position)); + } + if (handled) { + mHasPerformedLongPress = true; + view.setPressed(false); + setPressed(false); + } + } + + // Release + v = null; + } + } + + private final class UnsetPressedState implements Runnable { + View v; + + @Override + public void run() { + if (v != null) { + v.setPressed(false); + setPressed(false); + + // Release + v = null; + } + } + } + + private class PerformClick extends WindowRunnable implements Runnable { + View v; + int p; + + @Override + public void run() { + View view = v; + int position = p; + if (view != null && position >= 0 && sameWindow()) { + performItemClick(view, position, mAdapter.getItemId(position)); + } + + // Release + v = null; + } + } + + /** + * A base class for Runnables that will check that their view is still attached to + * the original window as when the Runnable was created. + */ + private class WindowRunnable { + private int mOriginalAttachCount; + + public void rememberWindowAttachCount() { + mOriginalAttachCount = getWindowAttachCount(); + } + + public boolean sameWindow() { + return getWindowAttachCount() == mOriginalAttachCount; + } + } + + /** + * Interface definition for a callback to be invoked when an item in the + * EasyRecyclerView has been clicked. + */ + public interface OnItemClickListener { + /** + * Callback method to be invoked when an item in the EasyRecyclerView + * has been clicked. + * + * @param parent The EasyRecyclerView where the click happened. + * @param view The view within the EasyRecyclerView that was clicked + * @param position The position of the view in the adapter. + * @param id The row id of the item that was clicked. + * @return true if the callback consumed the click, false otherwise + */ + boolean onItemClick(EasyRecyclerView parent, View view, int position, long id); + } + + /** + * Interface definition for a callback to be invoked when an item in the + * EasyRecyclerView has been clicked and held. + */ + public interface OnItemLongClickListener { + /** + * Callback method to be invoked when an item in the EasyRecyclerView + * has been clicked and held. + * + * @param parent The EasyRecyclerView where the click happened + * @param view The view within the EasyRecyclerView that was clicked + * @param position The position of the view in the list + * @param id The row id of the item that was clicked + * @return true if the callback consumed the long click, false otherwise + */ + boolean onItemLongClick(EasyRecyclerView parent, View view, int position, long id); + } + + /** + * Interface definition for a callback to be invoked when draw selector. + */ + public interface OnDrawSelectorListener { + /** + * Callback method to be invoked before selector draw. + * + * @param position The EasyRecyclerView where the click happened + * @return true if need draw selector, false otherwise + */ + boolean beforeDrawSelector(int position); + } + + /** + * A MultiChoiceModeListener receives events for {@link android.widget.AbsListView#CHOICE_MODE_MULTIPLE_MODAL}. + * It acts as the {@link android.view.ActionMode.Callback} for the selection mode and also receives + * {@link #onItemCheckedStateChanged(android.view.ActionMode, int, long, boolean)} events when the user + * selects and deselects list items. + */ + public interface MultiChoiceModeListener extends ActionMode.Callback { + /** + * Called when an item is checked or unchecked during selection mode. + * + * @param mode The {@link android.view.ActionMode} providing the selection mode + * @param position Adapter position of the item that was checked or unchecked + * @param id Adapter ID of the item that was checked or unchecked + * @param checked true if the item is now checked, false + * if the item is now unchecked. + */ + void onItemCheckedStateChanged(ActionMode mode, + int position, long id, boolean checked); + } + + class MultiChoiceModeWrapper implements MultiChoiceModeListener { + private MultiChoiceModeListener mWrapped; + + public void setWrapped(MultiChoiceModeListener wrapped) { + mWrapped = wrapped; + } + + public boolean hasWrappedCallback() { + return mWrapped != null; + } + + @Override + public boolean onCreateActionMode(ActionMode mode, Menu menu) { + if (mWrapped.onCreateActionMode(mode, menu)) { + // Initialize checked graphic state? + setLongClickable(false); + return true; + } + return false; + } + + @Override + public boolean onPrepareActionMode(ActionMode mode, Menu menu) { + return mWrapped.onPrepareActionMode(mode, menu); + } + + @Override + public boolean onActionItemClicked(ActionMode mode, MenuItem item) { + return mWrapped.onActionItemClicked(mode, item); + } + + @Override + public void onDestroyActionMode(ActionMode mode) { + mWrapped.onDestroyActionMode(mode); + mChoiceActionMode = null; + + // Ending selection mode means deselecting everything. + clearChoices(); + + requestLayout(); + + setLongClickable(true); + } + + @Override + public void onItemCheckedStateChanged(ActionMode mode, + int position, long id, boolean checked) { + mWrapped.onItemCheckedStateChanged(mode, position, id, checked); + + // If there are no items selected we no longer need the selection mode. + if (getCheckedItemCount() == 0) { + mode.finish(); + } + } + } + + /** + * Custom checked + */ + public interface CustomChoiceListener { + + void onIntoCustomChoice(EasyRecyclerView view); + + void onOutOfCustomChoice(EasyRecyclerView view); + + void onItemCheckedStateChanged(EasyRecyclerView view, int position, long id, boolean checked); + } +} diff --git a/app/src/main/java/com/hippo/easyrecyclerview/FastScroller.java b/app/src/main/java/com/hippo/easyrecyclerview/FastScroller.java new file mode 100644 index 000000000..87a1fd8e3 --- /dev/null +++ b/app/src/main/java/com/hippo/easyrecyclerview/FastScroller.java @@ -0,0 +1,401 @@ +/* + * Copyright 2015 Hippo Seven + * + * 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.hippo.easyrecyclerview; + +import android.animation.Animator; +import android.animation.ObjectAnimator; +import android.content.Context; +import android.content.res.TypedArray; +import android.graphics.Canvas; +import android.graphics.drawable.Drawable; +import android.os.Handler; +import android.util.AttributeSet; +import android.view.MotionEvent; +import android.view.View; +import android.view.ViewConfiguration; + +import androidx.recyclerview.widget.RecyclerView; + +import com.hippo.ehviewer.R; +import com.hippo.yorozuya.AnimationUtils; +import com.hippo.yorozuya.LayoutUtils; +import com.hippo.yorozuya.MathUtils; +import com.hippo.yorozuya.SimpleAnimatorListener; +import com.hippo.yorozuya.SimpleHandler; + +public class FastScroller extends View { + + private static final int INVALID = -1; + + private static final int SCROLL_BAR_FADE_DURATION = 500; + private static final int SCROLL_BAR_DELAY = 1000; + + private static final int MIN_HANDLER_HEIGHT_DP = 32; + + private Handler mSimpleHandler; + + private boolean mDraggable; + + private int mMinHandlerHeight; + + private RecyclerView mRecyclerView; + private RecyclerView.OnScrollListener mOnScrollChangeListener; + private RecyclerView.Adapter mAdapter; + private RecyclerView.AdapterDataObserver mAdapterDataObserver; + + private Drawable mHandler; + private int mHandlerOffset = INVALID; + private int mHandlerHeight = INVALID; + + private float mDownX = INVALID; + private float mDownY = INVALID; + + private float mLastMotionY = INVALID; + + private boolean mDragged = false; + + private boolean mCantDrag = false; + + private int mTouchSlop; + + private OnDragHandlerListener mListener; + + private ObjectAnimator mShowAnimator; + private ObjectAnimator mHideAnimator; + + private final Runnable mHideRunnable = new Runnable() { + @Override + public void run() { + mHideAnimator.start(); + } + }; + + public FastScroller(Context context) { + super(context); + init(context, null, 0); + } + + public FastScroller(Context context, AttributeSet attrs) { + super(context, attrs); + init(context, attrs, 0); + } + + public FastScroller(Context context, AttributeSet attrs, int defStyleAttr) { + super(context, attrs, defStyleAttr); + init(context, attrs, defStyleAttr); + } + + private void init(Context context, AttributeSet attrs, int defStyleAttr) { + mSimpleHandler = SimpleHandler.getInstance(); + + TypedArray a = context.obtainStyledAttributes(attrs, R.styleable.FastScroller, defStyleAttr, 0); + mHandler = a.getDrawable(R.styleable.FastScroller_handler); + mDraggable = a.getBoolean(R.styleable.FastScroller_draggable, true); + a.recycle(); + + setAlpha(0.0f); + setVisibility(INVISIBLE); + + mMinHandlerHeight = LayoutUtils.dp2pix(context, MIN_HANDLER_HEIGHT_DP); + mTouchSlop = ViewConfiguration.get(context).getScaledTouchSlop(); + + mShowAnimator = ObjectAnimator.ofFloat(this, "alpha", 1.0f); + mShowAnimator.setInterpolator(AnimationUtils.FAST_SLOW_INTERPOLATOR); + mShowAnimator.setDuration(SCROLL_BAR_FADE_DURATION); + + mHideAnimator = ObjectAnimator.ofFloat(this, "alpha", 0.0f); + mHideAnimator.setInterpolator(AnimationUtils.SLOW_FAST_INTERPOLATOR); + mHideAnimator.setDuration(SCROLL_BAR_FADE_DURATION); + mHideAnimator.addListener(new SimpleAnimatorListener() { + + private boolean mCancel; + + @Override + public void onAnimationCancel(Animator animation) { + mCancel = true; + } + + @Override + public void onAnimationEnd(Animator animation) { + if (mCancel) { + mCancel = false; + } else { + setVisibility(INVISIBLE); + } + } + }); + } + + public void setOnDragHandlerListener(OnDragHandlerListener listener) { + mListener = listener; + } + + private void updatePosition(boolean show) { + if (mRecyclerView == null) { + return; + } + + int paddingTop = getPaddingTop(); + int paddingBottom = getPaddingBottom(); + int height = getHeight() - paddingTop - paddingBottom; + int offset = mRecyclerView.computeVerticalScrollOffset(); + int extent = mRecyclerView.computeVerticalScrollExtent(); + int range = mRecyclerView.computeVerticalScrollRange(); + + if (height <= 0 || extent >= range || extent <= 0) { + return; + } + + int endOffset = height * offset / range; + int endHeight = height * extent / range; + + endHeight = Math.max(endHeight, mMinHandlerHeight); + endOffset = Math.min(endOffset, height - endHeight); + + mHandlerOffset = endOffset + paddingTop; + mHandlerHeight = endHeight; + + if (show) { + if (mHideAnimator.isRunning()) { + mHideAnimator.cancel(); + mShowAnimator.start(); + } else if (getVisibility() != VISIBLE && !mShowAnimator.isRunning()) { + setVisibility(VISIBLE); + mShowAnimator.start(); + } + + Handler handler = mSimpleHandler; + handler.removeCallbacks(mHideRunnable); + + if (!mDragged) { + handler.postDelayed(mHideRunnable, SCROLL_BAR_DELAY); + } + } + } + + public void setHandlerDrawable(Drawable drawable) { + mHandler = drawable; + invalidate(); + } + + public boolean isDraggable() { + return mDraggable; + } + + public void setDraggable(boolean draggable) { + mDraggable = draggable; + if (mDragged) { + mDragged = false; + } + mSimpleHandler.removeCallbacks(mHideRunnable); + mHideRunnable.run(); + } + + public boolean isAttached() { + return mRecyclerView != null; + } + + public void attachToRecyclerView(RecyclerView recyclerView) { + if (recyclerView == null) { + return; + } + + if (mRecyclerView != null) { + throw new IllegalStateException("The FastScroller is already attached to a RecyclerView, " + + "call detachedFromRecyclerView first"); + } + + mRecyclerView = recyclerView; + mOnScrollChangeListener = new RecyclerView.OnScrollListener() { + + @Override + public void onScrollStateChanged(RecyclerView recyclerView, int newState) { + } + + @Override + public void onScrolled(RecyclerView recyclerView, int dx, int dy) { + updatePosition(true); + invalidate(); + } + }; + + recyclerView.addOnScrollListener(mOnScrollChangeListener); + + mAdapter = recyclerView.getAdapter(); + if (mAdapter != null) { + mAdapterDataObserver = new RecyclerView.AdapterDataObserver() { + @Override + public void onItemRangeChanged(int positionStart, int itemCount) { + super.onItemRangeChanged(positionStart, itemCount); + updatePosition(false); + invalidate(); + } + + @Override + public void onItemRangeChanged(int positionStart, int itemCount, Object payload) { + super.onItemRangeChanged(positionStart, itemCount, payload); + updatePosition(false); + invalidate(); + } + + @Override + public void onItemRangeInserted(int positionStart, int itemCount) { + super.onItemRangeInserted(positionStart, itemCount); + updatePosition(false); + invalidate(); + } + + @Override + public void onItemRangeRemoved(int positionStart, int itemCount) { + super.onItemRangeRemoved(positionStart, itemCount); + updatePosition(false); + invalidate(); + } + + @Override + public void onItemRangeMoved(int fromPosition, int toPosition, int itemCount) { + super.onItemRangeMoved(fromPosition, toPosition, itemCount); + updatePosition(false); + invalidate(); + } + }; + mAdapter.registerAdapterDataObserver(mAdapterDataObserver); + } + } + + public void detachedFromRecyclerView() { + if (mRecyclerView != null && mOnScrollChangeListener != null) { + mRecyclerView.removeOnScrollListener(mOnScrollChangeListener); + } + mRecyclerView = null; + mOnScrollChangeListener = null; + + if (mAdapter != null && mAdapterDataObserver != null) { + mAdapter.unregisterAdapterDataObserver(mAdapterDataObserver); + } + mAdapter = null; + mAdapterDataObserver = null; + + setAlpha(0.0f); + setVisibility(INVISIBLE); + } + + @Override + protected void onDraw(Canvas canvas) { + if (mRecyclerView == null || mHandler == null) { + return; + } + if (mHandlerHeight == INVALID) { + updatePosition(false); + } + if (mHandlerHeight == INVALID) { + return; + } + + int paddingLeft = getPaddingLeft(); + int saved = canvas.save(); + canvas.translate(paddingLeft, mHandlerOffset); + mHandler.setBounds(0, 0, getWidth() - paddingLeft - getPaddingRight(), mHandlerHeight); + mHandler.draw(canvas); + canvas.restoreToCount(saved); + } + + @Override + public boolean onTouchEvent(MotionEvent event) { + if (!mDraggable || getVisibility() != VISIBLE || mRecyclerView == null || mHandlerHeight == INVALID) { + return false; + } + + int action = event.getAction(); + if (action == MotionEvent.ACTION_DOWN) { + mCantDrag = false; + } + + if (mCantDrag) { + return false; + } + + switch (action) { + case MotionEvent.ACTION_DOWN: { + mDragged = false; + mDownX = event.getX(); + mDownY = event.getY(); + if (mDownY < mHandlerOffset || mDownY > mHandlerOffset + mHandlerHeight) { + mCantDrag = true; + return false; + } + break; + } + case MotionEvent.ACTION_MOVE: { + if (!mDragged) { + float x = event.getX(); + float y = event.getY(); + // Check touch slop + if (MathUtils.dist(x, y, mDownX, mDownY) < mTouchSlop) { + return true; + } + if (Math.abs(x - mDownX) > Math.abs(y - mDownY) || + y < mHandlerOffset || y > mHandlerOffset + mHandlerHeight) { + mCantDrag = true; + return false; + } else { + mDragged = true; + mSimpleHandler.removeCallbacks(mHideRunnable); + // Update mLastMotionY + if (mDownY < mHandlerOffset || mDownY >= mHandlerOffset + mHandlerHeight) { + // the point out of handler, make the point in handler center + mLastMotionY = mHandlerOffset + mHandlerHeight / 2; + } else { + mLastMotionY = mDownY; + } + // Notify + if (mListener != null) { + mListener.onStartDragHandler(); + } + } + } + + int range = mRecyclerView.computeVerticalScrollRange(); + if (range <= 0) { + break; + } + float y = event.getY(); + int scroll = (int) (range * (y - mLastMotionY) / (getHeight() - getPaddingTop() - getPaddingBottom())); + mRecyclerView.scrollBy(0, scroll); + mLastMotionY = y; + break; + } + case MotionEvent.ACTION_UP: + case MotionEvent.ACTION_CANCEL: + // Notify + if (mDragged && mListener != null) { + mListener.onEndDragHandler(); + } + mDragged = false; + mSimpleHandler.postDelayed(mHideRunnable, SCROLL_BAR_DELAY); + break; + } + + return true; + } + + public interface OnDragHandlerListener { + void onStartDragHandler(); + + void onEndDragHandler(); + } +} diff --git a/app/src/main/java/com/hippo/easyrecyclerview/HandlerDrawable.java b/app/src/main/java/com/hippo/easyrecyclerview/HandlerDrawable.java new file mode 100644 index 000000000..d63390083 --- /dev/null +++ b/app/src/main/java/com/hippo/easyrecyclerview/HandlerDrawable.java @@ -0,0 +1,84 @@ +/* + * Copyright 2015 Hippo Seven + * + * 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.hippo.easyrecyclerview; + +import android.graphics.Canvas; +import android.graphics.Color; +import android.graphics.ColorFilter; +import android.graphics.Paint; +import android.graphics.PixelFormat; +import android.graphics.RectF; +import android.graphics.drawable.Drawable; + +public class HandlerDrawable extends Drawable { + + private int mColor = Color.BLACK; + private final Paint mPaint; + private final RectF mTemp = new RectF(); + + public HandlerDrawable() { + mPaint = new Paint(Paint.ANTI_ALIAS_FLAG); + mPaint.setColor(mColor); + mPaint.setStyle(Paint.Style.FILL); + } + + @Override + public void draw(Canvas canvas) { + int width = getBounds().width(); + int height = getBounds().height(); + + if (width > height) { + canvas.drawRect(0, 0, width, height, mPaint); + } else { + mTemp.set(0, 0, width, width); + canvas.drawArc(mTemp, -180, 180, true, mPaint); + mTemp.set(0, height - width, width, height); + canvas.drawArc(mTemp, 0, 180, true, mPaint); + float halfWidth = (float) width / 2.0f; + canvas.drawRect(0, halfWidth, width, height - halfWidth, mPaint); + } + } + + public void setColor(int color) { + if (mColor != color) { + mColor = color; + mPaint.setColor(color); + } + } + + @Override + public void setAlpha(int alpha) { + mPaint.setAlpha(alpha); + } + + @Override + public void setColorFilter(ColorFilter colorFilter) { + mPaint.setColorFilter(colorFilter); + } + + @Override + public int getOpacity() { + int alpha = Color.alpha(mColor); + if (alpha == 0xff) { + return PixelFormat.OPAQUE; + } else if (alpha == 0x00) { + return PixelFormat.TRANSPARENT; + } else { + return PixelFormat.TRANSLUCENT; + } + } +} diff --git a/app/src/main/java/com/hippo/easyrecyclerview/LayoutManagerUtils.java b/app/src/main/java/com/hippo/easyrecyclerview/LayoutManagerUtils.java new file mode 100644 index 000000000..07b5b326c --- /dev/null +++ b/app/src/main/java/com/hippo/easyrecyclerview/LayoutManagerUtils.java @@ -0,0 +1,158 @@ +/* + * Copyright 2015 Hippo Seven + * + * 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.hippo.easyrecyclerview; + +import android.content.Context; +import android.graphics.PointF; + +import androidx.recyclerview.widget.LinearLayoutManager; +import androidx.recyclerview.widget.RecyclerView; +import androidx.recyclerview.widget.StaggeredGridLayoutManager; + +import com.hippo.yorozuya.MathUtils; +import com.hippo.yorozuya.SimpleHandler; + +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Method; + +public final class LayoutManagerUtils { + + private static Method sCsdfp = null; + + static { + try { + sCsdfp = StaggeredGridLayoutManager.class.getDeclaredMethod("calculateScrollDirectionForPosition", int.class); + sCsdfp.setAccessible(true); + } catch (NoSuchMethodException e) { + // Ignore + e.printStackTrace(); + } + } + + public static void scrollToPositionWithOffset( + RecyclerView.LayoutManager layoutManager, int position, int offset) { + if (layoutManager instanceof LinearLayoutManager) { + ((LinearLayoutManager) layoutManager).scrollToPositionWithOffset(position, offset); + } else if (layoutManager instanceof StaggeredGridLayoutManager) { + ((StaggeredGridLayoutManager) layoutManager).scrollToPositionWithOffset(position, offset); + } else { + throw new IllegalStateException("Can't do scrollToPositionWithOffset for " + + layoutManager.getClass().getName()); + } + } + + public static void smoothScrollToPosition( + RecyclerView.LayoutManager layoutManager, Context context, int position) { + smoothScrollToPosition(layoutManager, context, position, -1); + } + + public static void smoothScrollToPosition( + RecyclerView.LayoutManager layoutManager, Context context, int position, + int millisecondsPerInch) { + SimpleSmoothScroller smoothScroller; + if (layoutManager instanceof LinearLayoutManager) { + final LinearLayoutManager linearLayoutManager = (LinearLayoutManager) layoutManager; + smoothScroller = new SimpleSmoothScroller(context, millisecondsPerInch) { + @Override + public PointF computeScrollVectorForPosition(int targetPosition) { + return linearLayoutManager.computeScrollVectorForPosition(targetPosition); + } + }; + + } else if (layoutManager instanceof StaggeredGridLayoutManager) { + final StaggeredGridLayoutManager staggeredGridLayoutManager = (StaggeredGridLayoutManager) layoutManager; + smoothScroller = new SimpleSmoothScroller(context, millisecondsPerInch) { + @Override + public PointF computeScrollVectorForPosition(int targetPosition) { + int direction = 0; + try { + direction = (Integer) sCsdfp.invoke(staggeredGridLayoutManager, targetPosition); + } catch (IllegalAccessException e) { + e.printStackTrace(); + } catch (InvocationTargetException e) { + e.printStackTrace(); + } + + if (direction == 0) { + return null; + } + if (staggeredGridLayoutManager.getOrientation() == StaggeredGridLayoutManager.HORIZONTAL) { + return new PointF(direction, 0); + } else { + return new PointF(0, direction); + } + } + }; + + } else { + throw new IllegalStateException("Can't do smoothScrollToPosition for " + + layoutManager.getClass().getName()); + } + smoothScroller.setTargetPosition(position); + layoutManager.startSmoothScroll(smoothScroller); + } + + public static void scrollToPositionProperly(final RecyclerView.LayoutManager layoutManager, + final Context context, final int position, final OnScrollToPositionListener listener) { + SimpleHandler.getInstance().postDelayed(new Runnable() { + @Override + public void run() { + int first = getFirstVisibleItemPosition(layoutManager); + int last = getLastVisibleItemPosition(layoutManager); + int offset = Math.abs(position - first); + int max = last - first; + if (offset < max && max > 0) { + smoothScrollToPosition(layoutManager, context, position, + MathUtils.lerp(100, 25, (offset / max))); + } else { + scrollToPositionWithOffset(layoutManager, position, 0); + if (listener != null) { + listener.onScrollToPosition(position); + } + } + } + }, 200); + } + + public static int getFirstVisibleItemPosition(RecyclerView.LayoutManager layoutManager) { + if (layoutManager instanceof LinearLayoutManager) { + return ((LinearLayoutManager) layoutManager).findFirstVisibleItemPosition(); + } else if (layoutManager instanceof StaggeredGridLayoutManager) { + int[] positions = ((StaggeredGridLayoutManager) layoutManager).findFirstVisibleItemPositions(null); + return MathUtils.min(positions); + } else { + throw new IllegalStateException("Can't do getFirstVisibleItemPosition for " + + layoutManager.getClass().getName()); + } + } + + public static int getLastVisibleItemPosition(RecyclerView.LayoutManager layoutManager) { + if (layoutManager instanceof LinearLayoutManager) { + return ((LinearLayoutManager) layoutManager).findLastVisibleItemPosition(); + } else if (layoutManager instanceof StaggeredGridLayoutManager) { + int[] positions = ((StaggeredGridLayoutManager) layoutManager).findLastVisibleItemPositions(null); + return MathUtils.max(positions); + } else { + throw new IllegalStateException("Can't do getLastVisibleItemPosition for " + + layoutManager.getClass().getName()); + } + } + + public interface OnScrollToPositionListener { + void onScrollToPosition(int position); + } +} diff --git a/app/src/main/java/com/hippo/easyrecyclerview/LinearDividerItemDecoration.java b/app/src/main/java/com/hippo/easyrecyclerview/LinearDividerItemDecoration.java new file mode 100644 index 000000000..c031a9480 --- /dev/null +++ b/app/src/main/java/com/hippo/easyrecyclerview/LinearDividerItemDecoration.java @@ -0,0 +1,274 @@ +/* + * Copyright (C) 2015 Hippo Seven + * + * 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.hippo.easyrecyclerview; + +import android.graphics.Canvas; +import android.graphics.Paint; +import android.graphics.Rect; +import android.view.View; + +import androidx.core.view.ViewCompat; +import androidx.recyclerview.widget.LinearLayoutManager; +import androidx.recyclerview.widget.RecyclerView; + +// Add overlap + +/** + * Only work for {@link androidx.recyclerview.widget.LinearLayoutManager}.
+ * Show divider between item, just like + * {@link android.widget.ListView#setDivider(android.graphics.drawable.Drawable)} + */ +public class LinearDividerItemDecoration extends RecyclerView.ItemDecoration { + public static final int HORIZONTAL = LinearLayoutManager.HORIZONTAL; + public static final int VERTICAL = LinearLayoutManager.VERTICAL; + + private boolean mShowFirstDivider = false; + private boolean mShowLastDivider = false; + + private final Rect mRect; + private final Paint mPaint; + + private int mOrientation; + private int mThickness; + private int mPaddingStart = 0; + private int mPaddingEnd = 0; + + private boolean mOverlap = false; + + private ShowDividerHelper mShowDividerHelper; + + public LinearDividerItemDecoration(int orientation, int color, int thickness) { + mRect = new Rect(); + mPaint = new Paint(); + mPaint.setStyle(Paint.Style.FILL); + setOrientation(orientation); + setColor(color); + setThickness(thickness); + } + + public void setShowDividerHelper(ShowDividerHelper showDividerHelper) { + mShowDividerHelper = showDividerHelper; + } + + public void setOrientation(int orientation) { + if (orientation != HORIZONTAL && orientation != VERTICAL) { + throw new IllegalArgumentException("invalid orientation"); + } + mOrientation = orientation; + } + + public void setColor(int color) { + mPaint.setColor(color); + } + + public void setThickness(int thickness) { + mThickness = thickness; + } + + public void setShowFirstDivider(boolean showFirstDivider) { + mShowFirstDivider = showFirstDivider; + } + + public void setShowLastDivider(boolean showLastDivider) { + mShowLastDivider = showLastDivider; + } + + public void setPadding(int padding) { + setPaddingStart(padding); + setPaddingEnd(padding); + } + + public void setPaddingStart(int paddingStart) { + mPaddingStart = paddingStart; + } + + public void setPaddingEnd(int paddingEnd) { + mPaddingEnd = paddingEnd; + } + + public void setOverlap(boolean overlap) { + mOverlap = overlap; + } + + @Override + public void getItemOffsets(Rect outRect, View view, + RecyclerView parent, RecyclerView.State state) { + if (parent.getAdapter() == null) { + outRect.set(0, 0, 0, 0); + return; + } + + if (mOverlap) { + outRect.set(0, 0, 0, 0); + return; + } + + final int position = parent.getChildLayoutPosition(view); + final int itemCount = parent.getAdapter().getItemCount(); + + if (mShowDividerHelper != null) { + if (mOrientation == VERTICAL) { + if (position == 0 && mShowDividerHelper.showDivider(0)) { + outRect.top = mThickness; + } + if (mShowDividerHelper.showDivider(position + 1)) { + outRect.bottom = mThickness; + } + } else { + if (position == 0 && mShowDividerHelper.showDivider(0)) { + outRect.left = mThickness; + } + if (mShowDividerHelper.showDivider(position + 1)) { + outRect.right = mThickness; + } + } + } else { + if (mOrientation == VERTICAL) { + if (position == 0 && mShowFirstDivider) { + outRect.top = mThickness; + } + outRect.bottom = mThickness; + if (position == itemCount - 1 && !mShowLastDivider) { + outRect.bottom = 0; + } + } else { + if (position == 0 && mShowFirstDivider) { + outRect.left = mThickness; + } + outRect.right = mThickness; + if (position == itemCount - 1 && !mShowLastDivider) { + outRect.right = 0; + } + } + } + } + + @Override + public void onDrawOver(Canvas c, RecyclerView parent, + RecyclerView.State state) { + RecyclerView.Adapter adapter = parent.getAdapter(); + if (adapter == null) { + return; + } + + int itemCount = adapter.getItemCount(); + boolean overlap = mOverlap; + + if (mOrientation == VERTICAL) { + final boolean isRtl = ViewCompat.getLayoutDirection(parent) == ViewCompat.LAYOUT_DIRECTION_RTL; + int mPaddingLeft; + int mPaddingRight; + if (isRtl) { + mPaddingLeft = mPaddingEnd; + mPaddingRight = mPaddingStart; + } else { + mPaddingLeft = mPaddingStart; + mPaddingRight = mPaddingEnd; + } + + final int left = parent.getPaddingLeft() + mPaddingLeft; + final int right = parent.getWidth() - parent.getPaddingRight() - mPaddingRight; + final int childCount = parent.getChildCount(); + + for (int i = 0; i < childCount; i++) { + final View child = parent.getChildAt(i); + final RecyclerView.LayoutParams lp = (RecyclerView.LayoutParams) child.getLayoutParams(); + final int position = parent.getChildLayoutPosition(child); + + boolean show; + if (mShowDividerHelper != null) { + show = mShowDividerHelper.showDivider(position + 1); + } else { + show = position != itemCount - 1 || mShowLastDivider; + } + if (show) { + int top = child.getBottom() + lp.bottomMargin; + if (overlap) { + top -= mThickness; + } + final int bottom = top + mThickness; + mRect.set(left, top, right, bottom); + c.drawRect(mRect, mPaint); + } + + if (position == 0) { + if (mShowDividerHelper != null) { + show = mShowDividerHelper.showDivider(0); + } else { + show = mShowFirstDivider; + } + if (show) { + int bottom = child.getTop() + lp.topMargin; + if (overlap) { + bottom += mThickness; + } + final int top = bottom - mThickness; + mRect.set(left, top, right, bottom); + c.drawRect(mRect, mPaint); + } + } + } + } else { + final int top = parent.getPaddingTop() + mPaddingStart; + final int bottom = parent.getHeight() - parent.getPaddingBottom() - mPaddingEnd; + final int childCount = parent.getChildCount(); + + for (int i = 0; i < childCount; i++) { + final View child = parent.getChildAt(i); + final RecyclerView.LayoutParams lp = (RecyclerView.LayoutParams) child.getLayoutParams(); + final int position = parent.getChildLayoutPosition(child); + + boolean show; + if (mShowDividerHelper != null) { + show = mShowDividerHelper.showDivider(position + 1); + } else { + show = position != itemCount - 1 || mShowLastDivider; + } + if (show) { + int left = child.getRight() + lp.rightMargin; + if (overlap) { + left -= mThickness; + } + final int right = left + mThickness; + mRect.set(left, top, right, bottom); + c.drawRect(mRect, mPaint); + } + + if (position == 0) { + if (mShowDividerHelper != null) { + show = mShowDividerHelper.showDivider(0); + } else { + show = mShowFirstDivider; + } + if (show) { + int right = child.getLeft() + lp.leftMargin; + if (overlap) { + right += mThickness; + } + final int left = right - mThickness; + mRect.set(left, top, right, bottom); + c.drawRect(mRect, mPaint); + } + } + } + } + } + + public interface ShowDividerHelper { + boolean showDivider(int index); + } +} diff --git a/app/src/main/java/com/hippo/easyrecyclerview/MarginItemDecoration.java b/app/src/main/java/com/hippo/easyrecyclerview/MarginItemDecoration.java new file mode 100644 index 000000000..9245fadf9 --- /dev/null +++ b/app/src/main/java/com/hippo/easyrecyclerview/MarginItemDecoration.java @@ -0,0 +1,63 @@ +/* + * Copyright (C) 2015 Hippo Seven + * + * 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.hippo.easyrecyclerview; + +import android.graphics.Rect; +import android.view.View; + +import androidx.recyclerview.widget.RecyclerView; + +public class MarginItemDecoration extends RecyclerView.ItemDecoration { + + private int mMargin; + private int mPaddingLeft; + private int mPaddingTop; + private int mPaddingRight; + private int mPaddingBottom; + + public MarginItemDecoration(int margin, int paddingLeft, int paddingTop, + int paddingRight, int paddingBottom) { + setMargin(margin, paddingLeft, paddingTop, paddingRight, paddingBottom); + } + + @Override + public void getItemOffsets(Rect outRect, View view, + RecyclerView parent, RecyclerView.State state) { + outRect.set(mMargin, mMargin, mMargin, mMargin); + } + + /** + * @param margin gap between two item + * @param paddingLeft gap between RecyclerView left and left item left + * @param paddingTop gap between RecyclerView top and top item top + * @param paddingRight gap between RecyclerView right and right item right + * @param paddingBottom gap between RecyclerView bottom and bottom item bottom + */ + public void setMargin(int margin, int paddingLeft, int paddingTop, + int paddingRight, int paddingBottom) { + int halfMargin = margin / 2; + mMargin = halfMargin; + mPaddingLeft = paddingLeft - halfMargin; + mPaddingTop = paddingTop - halfMargin; + mPaddingRight = paddingRight - halfMargin; + mPaddingBottom = paddingBottom - halfMargin; + } + + public void applyPaddings(View view) { + view.setPadding(mPaddingLeft, mPaddingTop, mPaddingRight, mPaddingBottom); + } +} diff --git a/app/src/main/java/com/hippo/easyrecyclerview/RawMarginItemDecoration.java b/app/src/main/java/com/hippo/easyrecyclerview/RawMarginItemDecoration.java new file mode 100644 index 000000000..8f54d9730 --- /dev/null +++ b/app/src/main/java/com/hippo/easyrecyclerview/RawMarginItemDecoration.java @@ -0,0 +1,49 @@ +/* + * Copyright 2016 Hippo Seven + * + * 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.hippo.easyrecyclerview; + +import android.graphics.Rect; +import android.view.View; + +import androidx.recyclerview.widget.RecyclerView; + +public class RawMarginItemDecoration extends RecyclerView.ItemDecoration { + + private int mMarginLeft; + private int mMarginTop; + private int mMarginRight; + private int mMarginBottom; + + public RawMarginItemDecoration(int marginLeft, int marginTop, + int marginRight, int marginBottom) { + setMargin(marginLeft, marginTop, marginRight, marginBottom); + } + + public void setMargin(int marginLeft, int marginTop, + int marginRight, int marginBottom) { + mMarginLeft = marginLeft; + mMarginTop = marginTop; + mMarginRight = marginRight; + mMarginBottom = marginBottom; + } + + @Override + public void getItemOffsets(Rect outRect, View view, + RecyclerView parent, RecyclerView.State state) { + outRect.set(mMarginLeft, mMarginTop, mMarginRight, mMarginBottom); + } +} diff --git a/app/src/main/java/com/hippo/easyrecyclerview/SimpleHolder.java b/app/src/main/java/com/hippo/easyrecyclerview/SimpleHolder.java new file mode 100644 index 000000000..9593abdcf --- /dev/null +++ b/app/src/main/java/com/hippo/easyrecyclerview/SimpleHolder.java @@ -0,0 +1,28 @@ +/* + * Copyright (C) 2015 Hippo Seven + * + * 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.hippo.easyrecyclerview; + +import android.view.View; + +import androidx.recyclerview.widget.RecyclerView; + +public class SimpleHolder extends RecyclerView.ViewHolder { + + public SimpleHolder(View itemView) { + super(itemView); + } +} diff --git a/app/src/main/java/com/hippo/easyrecyclerview/SimpleSmoothScroller.java b/app/src/main/java/com/hippo/easyrecyclerview/SimpleSmoothScroller.java new file mode 100644 index 000000000..a92381a4a --- /dev/null +++ b/app/src/main/java/com/hippo/easyrecyclerview/SimpleSmoothScroller.java @@ -0,0 +1,40 @@ +/* + * Copyright (C) 2015 Hippo Seven + * + * 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.hippo.easyrecyclerview; + +import android.content.Context; + +import androidx.recyclerview.widget.LinearSmoothScroller; + +public abstract class SimpleSmoothScroller extends LinearSmoothScroller { + + private final float mMillisecondsPerPx; + + public SimpleSmoothScroller(Context context, float millisecondsPerInch) { + super(context); + mMillisecondsPerPx = millisecondsPerInch / context.getResources().getDisplayMetrics().densityDpi; + } + + @Override + protected int calculateTimeForScrolling(int dx) { + if (mMillisecondsPerPx <= 0) { + return super.calculateTimeForScrolling(dx); + } else { + return (int) Math.ceil(Math.abs(dx) * mMillisecondsPerPx); + } + } +} diff --git a/app/src/main/java/com/hippo/ehviewer/Settings.java b/app/src/main/java/com/hippo/ehviewer/Settings.java index 7f5b414e5..13d00e7d5 100755 --- a/app/src/main/java/com/hippo/ehviewer/Settings.java +++ b/app/src/main/java/com/hippo/ehviewer/Settings.java @@ -133,6 +133,8 @@ public class Settings { private static final boolean DEFAULT_CELLULAR_NETWORK_WARNING = false; private static final String KEY_NIGHT_MODE = "night_mode"; private static final String DEFAULT_NIGHT_MODE = "-1"; + private static final String KEY_E_INK_MODE = "e_ink_mode"; + private static final boolean DEFAULT_E_INK_MODE = false; /******************** ****** Read ********************/ @@ -560,6 +562,14 @@ public class Settings { putString(KEY_EXCLUDED_LANGUAGES, value); } + public static void putEInkMode(boolean value) { + putBoolean(KEY_E_INK_MODE, value); + } + + public static boolean getEInkMode() { + return getBoolean(KEY_E_INK_MODE, DEFAULT_E_INK_MODE); + } + public static boolean getCellularNetworkWarning() { return getBoolean(KEY_CELLULAR_NETWORK_WARNING, DEFAULT_CELLULAR_NETWORK_WARNING); } diff --git a/app/src/main/java/com/hippo/ehviewer/ui/scene/GalleryListScene.java b/app/src/main/java/com/hippo/ehviewer/ui/scene/GalleryListScene.java index 0fab89cba..1ae56a48c 100644 --- a/app/src/main/java/com/hippo/ehviewer/ui/scene/GalleryListScene.java +++ b/app/src/main/java/com/hippo/ehviewer/ui/scene/GalleryListScene.java @@ -567,12 +567,8 @@ public final class GalleryListScene extends BaseScene mAdapter = new GalleryListAdapter(inflater, resources, mRecyclerView, Settings.getListMode()); - //mRecyclerView.setSelector(Ripple.generateRippleDrawable(context, !AttrResources.getAttrBoolean(context, R.attr.isLightTheme), new ColorDrawable(Color.TRANSPARENT))); - //mRecyclerView.setDrawSelectorOnTop(true); mRecyclerView.setClipToPadding(false); mRecyclerView.setClipChildren(false); - //mRecyclerView.setOnItemClickListener(this); - //mRecyclerView.setOnItemLongClickListener(this); mRecyclerView.addOnScrollListener(mOnScrollListener); fastScroller.setPadding(fastScroller.getPaddingLeft(), fastScroller.getPaddingTop() + paddingTopSB, diff --git a/app/src/main/java/com/hippo/ehviewer/widget/SearchBar.java b/app/src/main/java/com/hippo/ehviewer/widget/SearchBar.java index a8fd5fb11..9438888d7 100644 --- a/app/src/main/java/com/hippo/ehviewer/widget/SearchBar.java +++ b/app/src/main/java/com/hippo/ehviewer/widget/SearchBar.java @@ -152,7 +152,7 @@ public class SearchBar extends MaterialCardView implements View.OnClickListener, mListView.addItemDecoration(decoration); mListView.setLayoutManager(layoutManager); mListView.setOnItemClickListener((parent, view, position, id) -> { - if (position < mSuggestionList.size() - 1) { + if (position < mSuggestionList.size()) { mSuggestionList.get(position).onClick(); return true; } else { @@ -160,7 +160,7 @@ public class SearchBar extends MaterialCardView implements View.OnClickListener, } }); mListView.setOnItemLongClickListener((parent, view, position, id) -> { - if (position < mSuggestionList.size() - 1) { + if (position < mSuggestionList.size()) { return mSuggestionList.get(position).onLongClick(); } else { return false; diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index bb5dedc9e..096f37722 100755 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -738,4 +738,6 @@ 解决某些地区的 DNS 污染问题 色彩主题 跟随应用 + 墨水屏模式 + 为墨水屏做一些优化 diff --git a/app/src/main/res/values/arrays.xml b/app/src/main/res/values/arrays.xml index 1d05372b8..f373bf220 100644 --- a/app/src/main/res/values/arrays.xml +++ b/app/src/main/res/values/arrays.xml @@ -328,8 +328,8 @@ @string/follow_app - @string/theme_dark - @string/theme_light + @string/settings_eh_theme_light + @string/settings_eh_theme_dark diff --git a/app/src/main/res/values/attrs.xml b/app/src/main/res/values/attrs.xml index dc7f02360..a071a7f71 100644 --- a/app/src/main/res/values/attrs.xml +++ b/app/src/main/res/values/attrs.xml @@ -177,4 +177,9 @@ + + + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 64983952e..4bd4a7bfb 100755 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -802,4 +802,6 @@ Delete \"%s\" from search history? Color theme Follow app + E-Ink mode + Some optimizations for the ink screen. diff --git a/app/src/main/res/xml/advanced_settings.xml b/app/src/main/res/xml/advanced_settings.xml index 0524c5fb6..7af0864bf 100644 --- a/app/src/main/res/xml/advanced_settings.xml +++ b/app/src/main/res/xml/advanced_settings.xml @@ -93,6 +93,13 @@ android:title="@string/settings_advanced_dns_over_http_title" app:iconSpaceReserved="false" /> + +