From 9fc5475c878e9bd1e3ed3d1e04622ba0f82a8fa9 Mon Sep 17 00:00:00 2001 From: Fitz Date: Mon, 17 Oct 2022 15:09:32 +0800 Subject: [PATCH 1/8] add StickyHeaders but not ready --- .../me/yifeiyuan/flapdev/StickyHeaders.java | 28 + .../StickyHeadersLinearLayoutManager.java | 745 ++++++++++++++++++ 2 files changed, 773 insertions(+) create mode 100644 app/src/main/java/me/yifeiyuan/flapdev/StickyHeaders.java create mode 100644 app/src/main/java/me/yifeiyuan/flapdev/StickyHeadersLinearLayoutManager.java diff --git a/app/src/main/java/me/yifeiyuan/flapdev/StickyHeaders.java b/app/src/main/java/me/yifeiyuan/flapdev/StickyHeaders.java new file mode 100644 index 00000000..29d12990 --- /dev/null +++ b/app/src/main/java/me/yifeiyuan/flapdev/StickyHeaders.java @@ -0,0 +1,28 @@ +package me.yifeiyuan.flapdev; + +import android.view.View; + +/** + * Created by 程序亦非猿 on 2022/10/17. + */ +interface StickyHeaders { + + boolean isStickyHeader(int position); + + interface ViewSetup { + /** + * Adjusts any necessary properties of the {@code holder} that is being used as a sticky header. + * + * {@link #teardownStickyHeaderView(View)} will be called sometime after this method + * and before any other calls to this method go through. + */ + void setupStickyHeaderView(View stickyHeader); + + /** + * Reverts any properties changed in {@link #setupStickyHeaderView(View)}. + * + * Called after {@link #setupStickyHeaderView(View)}. + */ + void teardownStickyHeaderView(View stickyHeader); + } +} diff --git a/app/src/main/java/me/yifeiyuan/flapdev/StickyHeadersLinearLayoutManager.java b/app/src/main/java/me/yifeiyuan/flapdev/StickyHeadersLinearLayoutManager.java new file mode 100644 index 00000000..bce75e07 --- /dev/null +++ b/app/src/main/java/me/yifeiyuan/flapdev/StickyHeadersLinearLayoutManager.java @@ -0,0 +1,745 @@ +package me.yifeiyuan.flapdev; + +import android.content.Context; +import android.graphics.PointF; +import android.os.Parcel; +import android.os.Parcelable; +import android.view.View; +import android.view.ViewTreeObserver; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.recyclerview.widget.LinearLayoutManager; +import androidx.recyclerview.widget.RecyclerView; + +import java.util.ArrayList; +import java.util.List; + +/** + * Created by 程序亦非猿 on 2022/10/17. + * + * https://cs.android.com/android-studio/platform/tools/adt/idea/+/mirror-goog-studio-main:android-uitests/testData/CatchUp/app/src/main/kotlin/io/sweers/catchup/ui/StickyHeadersLinearLayoutManager.java?q=bindViewToPosition&start=1 + */ +class StickyHeadersLinearLayoutManager + extends LinearLayoutManager { + + private T mAdapter; + + private float mTranslationX; + private float mTranslationY; + + // Header positions for the currently displayed list and their observer. + private List mHeaderPositions = new ArrayList<>(0); + private RecyclerView.AdapterDataObserver mHeaderPositionsObserver = + new HeaderPositionsAdapterDataObserver(); + + // Sticky header's ViewHolder and dirty state. + private View mStickyHeader; + private int mStickyHeaderPosition = RecyclerView.NO_POSITION; + + private int mPendingScrollPosition = RecyclerView.NO_POSITION; + private int mPendingScrollOffset = 0; + + public StickyHeadersLinearLayoutManager(Context context) { + super(context); + } + + public StickyHeadersLinearLayoutManager(Context context, int orientation, boolean reverseLayout) { + super(context, orientation, reverseLayout); + } + + /** + * Offsets the vertical location of the sticky header relative to the its default position. + */ + public void setStickyHeaderTranslationY(float translationY) { + mTranslationY = translationY; + requestLayout(); + } + + /** + * Offsets the horizontal location of the sticky header relative to the its default position. + */ + public void setStickyHeaderTranslationX(float translationX) { + mTranslationX = translationX; + requestLayout(); + } + + /** + * Returns true if {@code view} is the current sticky header. + */ + public boolean isStickyHeader(View view) { + return view == mStickyHeader; + } + + @Override public void onAttachedToWindow(RecyclerView view) { + super.onAttachedToWindow(view); + setAdapter(view.getAdapter()); + } + + @Override + public void onAdapterChanged(RecyclerView.Adapter oldAdapter, RecyclerView.Adapter newAdapter) { + super.onAdapterChanged(oldAdapter, newAdapter); + setAdapter(newAdapter); + } + + @SuppressWarnings("unchecked") private void setAdapter(RecyclerView.Adapter adapter) { + if (mAdapter != null) { + mAdapter.unregisterAdapterDataObserver(mHeaderPositionsObserver); + } + + if (adapter instanceof StickyHeaders) { + mAdapter = (T) adapter; + mAdapter.registerAdapterDataObserver(mHeaderPositionsObserver); + mHeaderPositionsObserver.onChanged(); + } else { + mAdapter = null; + mHeaderPositions.clear(); + } + } + + @Override public Parcelable onSaveInstanceState() { + SavedState ss = new SavedState(); + ss.superState = super.onSaveInstanceState(); + ss.pendingScrollPosition = mPendingScrollPosition; + ss.pendingScrollOffset = mPendingScrollOffset; + return ss; + } + + @Override public void onRestoreInstanceState(Parcelable state) { + if (state instanceof SavedState) { + SavedState ss = (SavedState) state; + mPendingScrollPosition = ss.pendingScrollPosition; + mPendingScrollOffset = ss.pendingScrollOffset; + state = ss.superState; + } + + super.onRestoreInstanceState(state); + } + + @Override + public int scrollVerticallyBy(int dy, RecyclerView.Recycler recycler, RecyclerView.State state) { + detachStickyHeader(); + int scrolled = super.scrollVerticallyBy(dy, recycler, state); + attachStickyHeader(); + + if (scrolled != 0) { + updateStickyHeader(recycler, false); + } + + return scrolled; + } + + @Override public int scrollHorizontallyBy(int dx, + RecyclerView.Recycler recycler, + RecyclerView.State state) { + detachStickyHeader(); + int scrolled = super.scrollHorizontallyBy(dx, recycler, state); + attachStickyHeader(); + + if (scrolled != 0) { + updateStickyHeader(recycler, false); + } + + return scrolled; + } + + @Override public void onLayoutChildren(RecyclerView.Recycler recycler, RecyclerView.State state) { + detachStickyHeader(); + super.onLayoutChildren(recycler, state); + attachStickyHeader(); + + if (!state.isPreLayout()) { + updateStickyHeader(recycler, true); + } + } + + @Override public void scrollToPosition(int position) { + scrollToPositionWithOffset(position, INVALID_OFFSET); + } + + @Override public void scrollToPositionWithOffset(int position, int offset) { + scrollToPositionWithOffset(position, offset, true); + } + + private void scrollToPositionWithOffset(int position, int offset, boolean adjustForStickyHeader) { + // Reset pending scroll. + setPendingScroll(RecyclerView.NO_POSITION, INVALID_OFFSET); + + // Adjusting is disabled. + if (!adjustForStickyHeader) { + super.scrollToPositionWithOffset(position, offset); + return; + } + + // There is no header above or the position is a header. + int headerIndex = findHeaderIndexOrBefore(position); + if (headerIndex == -1 || findHeaderIndex(position) != -1) { + super.scrollToPositionWithOffset(position, offset); + return; + } + + // The position is right below a header, scroll to the header. + if (findHeaderIndex(position - 1) != -1) { + super.scrollToPositionWithOffset(position - 1, offset); + return; + } + + // Current sticky header is the same as at the position. Adjust the scroll offset and reset pending scroll. + if (mStickyHeader != null && headerIndex == findHeaderIndex(mStickyHeaderPosition)) { + int adjustedOffset = (offset != INVALID_OFFSET ? offset : 0) + mStickyHeader.getHeight(); + super.scrollToPositionWithOffset(position, adjustedOffset); + return; + } + + // Remember this position and offset and scroll to it to trigger creating the sticky header. + setPendingScroll(position, offset); + super.scrollToPositionWithOffset(position, offset); + } + + @Override public int computeVerticalScrollExtent(RecyclerView.State state) { + detachStickyHeader(); + int extent = super.computeVerticalScrollExtent(state); + attachStickyHeader(); + return extent; + } + + @Override public int computeVerticalScrollOffset(RecyclerView.State state) { + detachStickyHeader(); + int offset = super.computeVerticalScrollOffset(state); + attachStickyHeader(); + return offset; + } + + @Override public int computeVerticalScrollRange(RecyclerView.State state) { + detachStickyHeader(); + int range = super.computeVerticalScrollRange(state); + attachStickyHeader(); + return range; + } + + @Override public int computeHorizontalScrollExtent(RecyclerView.State state) { + detachStickyHeader(); + int extent = super.computeHorizontalScrollExtent(state); + attachStickyHeader(); + return extent; + } + + @Override public int computeHorizontalScrollOffset(RecyclerView.State state) { + detachStickyHeader(); + int offset = super.computeHorizontalScrollOffset(state); + attachStickyHeader(); + return offset; + } + + @Override public int computeHorizontalScrollRange(RecyclerView.State state) { + detachStickyHeader(); + int range = super.computeHorizontalScrollRange(state); + attachStickyHeader(); + return range; + } + + @Override public PointF computeScrollVectorForPosition(int targetPosition) { + detachStickyHeader(); + PointF vector = super.computeScrollVectorForPosition(targetPosition); + attachStickyHeader(); + return vector; + } + + @Override public View onFocusSearchFailed(View focused, + int focusDirection, + RecyclerView.Recycler recycler, + RecyclerView.State state) { + detachStickyHeader(); + View view = super.onFocusSearchFailed(focused, focusDirection, recycler, state); + attachStickyHeader(); + return view; + } + + private void detachStickyHeader() { + if (mStickyHeader != null) { + detachView(mStickyHeader); + } + } + + private void attachStickyHeader() { + if (mStickyHeader != null) { + attachView(mStickyHeader); + } + } + + /** + * Updates the sticky header state (creation, binding, display), to be called whenever there's a + * layout or scroll + */ + private void updateStickyHeader(RecyclerView.Recycler recycler, boolean layout) { + int headerCount = mHeaderPositions.size(); + int childCount = getChildCount(); + if (headerCount > 0 && childCount > 0) { + // Find first valid child. + View anchorView = null; + int anchorIndex = -1; + int anchorPos = -1; + for (int i = 0; i < childCount; i++) { + View child = getChildAt(i); + RecyclerView.LayoutParams params = (RecyclerView.LayoutParams) child.getLayoutParams(); + if (isViewValidAnchor(child, params)) { + anchorView = child; + anchorIndex = i; + anchorPos = params.getViewAdapterPosition(); + break; + } + } + if (anchorView != null && anchorPos != -1) { + int headerIndex = findHeaderIndexOrBefore(anchorPos); + int headerPos = headerIndex != -1 ? mHeaderPositions.get(headerIndex) : -1; + int nextHeaderPos = + headerCount > headerIndex + 1 ? mHeaderPositions.get(headerIndex + 1) : -1; + + // Show sticky header if: + // - There's one to show; + // - It's on the edge or it's not the anchor view; + // - Isn't followed by another sticky header; + if (headerPos != -1 + && (headerPos != anchorPos || isViewOnBoundary(anchorView)) + && nextHeaderPos != headerPos + 1) { + // Ensure existing sticky header, if any, is of correct type. + if (mStickyHeader != null && getItemViewType(mStickyHeader) != mAdapter.getItemViewType( + headerPos)) { + // A sticky header was shown before but is not of the correct type. Scrap it. + scrapStickyHeader(recycler); + } + + // Ensure sticky header is created, if absent, or bound, if being laid out or the position changed. + if (mStickyHeader == null) { + createStickyHeader(recycler, headerPos); + } + if (layout || getPosition(mStickyHeader) != headerPos) { + bindStickyHeader(recycler, headerPos); + } + + // Draw the sticky header using translation values which depend on orientation, direction and + // position of the next header view. + View nextHeaderView = null; + if (nextHeaderPos != -1) { + nextHeaderView = getChildAt(anchorIndex + (nextHeaderPos - anchorPos)); + // The header view itself is added to the RecyclerView. Discard it if it comes up. + if (nextHeaderView == mStickyHeader) { + nextHeaderView = null; + } + } + mStickyHeader.setTranslationX(getX(mStickyHeader, nextHeaderView)); + mStickyHeader.setTranslationY(getY(mStickyHeader, nextHeaderView)); + return; + } + } + } + + if (mStickyHeader != null) { + scrapStickyHeader(recycler); + } + } + + /** + * Creates {@link RecyclerView.ViewHolder} for {@code position}, including measure / layout, and + * assigns it to + * {@link #mStickyHeader}. + */ + private void createStickyHeader(@NonNull RecyclerView.Recycler recycler, int position) { + View stickyHeader = recycler.getViewForPosition(position); + + // Setup sticky header if the adapter requires it. + if (mAdapter instanceof StickyHeaders.ViewSetup) { + ((StickyHeaders.ViewSetup) mAdapter).setupStickyHeaderView(stickyHeader); + } + + // Add sticky header as a child view, to be detached / reattached whenever LinearLayoutManager#fill() is called, + // which happens on layout and scroll (see overrides). + addView(stickyHeader); + measureAndLayout(stickyHeader); + + // Ignore sticky header, as it's fully managed by this LayoutManager. + ignoreView(stickyHeader); + + mStickyHeader = stickyHeader; + mStickyHeaderPosition = position; + } + + /** + * Binds the {@link #mStickyHeader} for the given {@code position}. + */ + private void bindStickyHeader(@NonNull RecyclerView.Recycler recycler, int position) { + // Bind the sticky header. + recycler.bindViewToPosition(mStickyHeader, position); + mStickyHeaderPosition = position; + measureAndLayout(mStickyHeader); + + // If we have a pending scroll wait until the end of layout and scroll again. + if (mPendingScrollPosition != RecyclerView.NO_POSITION) { + final ViewTreeObserver vto = mStickyHeader.getViewTreeObserver(); + vto.addOnGlobalLayoutListener(new ViewTreeObserver.OnGlobalLayoutListener() { + @Override public void onGlobalLayout() { + vto.removeOnGlobalLayoutListener(this); + + if (mPendingScrollPosition != RecyclerView.NO_POSITION) { + scrollToPositionWithOffset(mPendingScrollPosition, mPendingScrollOffset); + setPendingScroll(RecyclerView.NO_POSITION, INVALID_OFFSET); + } + } + }); + } + } + + /** + * Measures and lays out {@code stickyHeader}. + */ + private void measureAndLayout(View stickyHeader) { + measureChildWithMargins(stickyHeader, 0, 0); + if (getOrientation() == VERTICAL) { + stickyHeader.layout(getPaddingLeft(), + 0, + getWidth() - getPaddingRight(), + stickyHeader.getMeasuredHeight()); + } else { + stickyHeader.layout(0, + getPaddingTop(), + stickyHeader.getMeasuredWidth(), + getHeight() - getPaddingBottom()); + } + } + + /** + * Returns {@link #mStickyHeader} to the {@link RecyclerView}'s {@link + * RecyclerView.RecycledViewPool}, assigning it + * to {@code null}. + * + * @param recycler If passed, the sticky header will be returned to the recycled view pool. + */ + private void scrapStickyHeader(@Nullable RecyclerView.Recycler recycler) { + View stickyHeader = mStickyHeader; + mStickyHeader = null; + mStickyHeaderPosition = RecyclerView.NO_POSITION; + + // Revert translation values. + stickyHeader.setTranslationX(0); + stickyHeader.setTranslationY(0); + + // Teardown holder if the adapter requires it. + if (mAdapter instanceof StickyHeaders.ViewSetup) { + ((StickyHeaders.ViewSetup) mAdapter).teardownStickyHeaderView(stickyHeader); + } + + // Stop ignoring sticky header so that it can be recycled. + stopIgnoringView(stickyHeader); + + // Remove and recycle sticky header. + removeView(stickyHeader); + if (recycler != null) { + recycler.recycleView(stickyHeader); + } + } + + /** + * Returns true when {@code view} is a valid anchor, ie. the first view to be valid and visible. + */ + private boolean isViewValidAnchor(View view, RecyclerView.LayoutParams params) { + if (!params.isItemRemoved() && !params.isViewInvalid()) { + if (getOrientation() == VERTICAL) { + if (getReverseLayout()) { + return view.getTop() + view.getTranslationY() <= getHeight() + mTranslationY; + } else { + return view.getBottom() - view.getTranslationY() >= mTranslationY; + } + } else { + if (getReverseLayout()) { + return view.getLeft() + view.getTranslationX() <= getWidth() + mTranslationX; + } else { + return view.getRight() - view.getTranslationX() >= mTranslationX; + } + } + } else { + return false; + } + } + + /** + * Returns true when the {@code view} is at the edge of the parent {@link RecyclerView}. + */ + private boolean isViewOnBoundary(View view) { + if (getOrientation() == VERTICAL) { + if (getReverseLayout()) { + return view.getBottom() - view.getTranslationY() > getHeight() + mTranslationY; + } else { + return view.getTop() + view.getTranslationY() < mTranslationY; + } + } else { + if (getReverseLayout()) { + return view.getRight() - view.getTranslationX() > getWidth() + mTranslationX; + } else { + return view.getLeft() + view.getTranslationX() < mTranslationX; + } + } + } + + /** + * Returns the position in the Y axis to position the header appropriately, depending on + * orientation, direction and + * {@link android.R.attr#clipToPadding}. + */ + private float getY(View headerView, View nextHeaderView) { + if (getOrientation() == VERTICAL) { + float y = mTranslationY; + if (getReverseLayout()) { + y += getHeight() - headerView.getHeight(); + } + if (nextHeaderView != null) { + if (getReverseLayout()) { + y = Math.max(nextHeaderView.getBottom(), y); + } else { + y = Math.min(nextHeaderView.getTop() - headerView.getHeight(), y); + } + } + return y; + } else { + return mTranslationY; + } + } + + /** + * Returns the position in the X axis to position the header appropriately, depending on + * orientation, direction and + * {@link android.R.attr#clipToPadding}. + */ + private float getX(View headerView, View nextHeaderView) { + if (getOrientation() != VERTICAL) { + float x = mTranslationX; + if (getReverseLayout()) { + x += getWidth() - headerView.getWidth(); + } + if (nextHeaderView != null) { + if (getReverseLayout()) { + x = Math.max(nextHeaderView.getRight(), x); + } else { + x = Math.min(nextHeaderView.getLeft() - headerView.getWidth(), x); + } + } + return x; + } else { + return mTranslationX; + } + } + + /** + * Finds the header index of {@code position} in {@code mHeaderPositions}. + */ + private int findHeaderIndex(int position) { + int low = 0; + int high = mHeaderPositions.size() - 1; + while (low <= high) { + int middle = (low + high) / 2; + if (mHeaderPositions.get(middle) > position) { + high = middle - 1; + } else if (mHeaderPositions.get(middle) < position) { + low = middle + 1; + } else { + return middle; + } + } + return -1; + } + + /** + * Finds the header index of {@code position} or the one before it in {@code mHeaderPositions}. + */ + private int findHeaderIndexOrBefore(int position) { + int low = 0; + int high = mHeaderPositions.size() - 1; + while (low <= high) { + int middle = (low + high) / 2; + if (mHeaderPositions.get(middle) > position) { + high = middle - 1; + } else if (middle < mHeaderPositions.size() - 1 + && mHeaderPositions.get(middle + 1) <= position) { + low = middle + 1; + } else { + return middle; + } + } + return -1; + } + + /** + * Finds the header index of {@code position} or the one next to it in {@code mHeaderPositions}. + */ + private int findHeaderIndexOrNext(int position) { + int low = 0; + int high = mHeaderPositions.size() - 1; + while (low <= high) { + int middle = (low + high) / 2; + if (middle > 0 && mHeaderPositions.get(middle - 1) >= position) { + high = middle - 1; + } else if (mHeaderPositions.get(middle) < position) { + low = middle + 1; + } else { + return middle; + } + } + return -1; + } + + private void setPendingScroll(int position, int offset) { + mPendingScrollPosition = position; + mPendingScrollOffset = offset; + } + + /** + * Handles header positions while adapter changes occur. + * + * This is used in detriment of {@link RecyclerView.LayoutManager}'s callbacks to control when + * they're received. + */ + private class HeaderPositionsAdapterDataObserver extends RecyclerView.AdapterDataObserver { + @Override public void onChanged() { + // There's no hint at what changed, so go through the adapter. + mHeaderPositions.clear(); + int itemCount = mAdapter.getItemCount(); + for (int i = 0; i < itemCount; i++) { + if (mAdapter.isStickyHeader(i)) { + mHeaderPositions.add(i); + } + } + + // Remove sticky header immediately if the entry it represents has been removed. A layout will follow. + if (mStickyHeader != null && !mHeaderPositions.contains(mStickyHeaderPosition)) { + scrapStickyHeader(null); + } + } + + @Override public void onItemRangeInserted(int positionStart, int itemCount) { + // Shift headers below down. + int headerCount = mHeaderPositions.size(); + if (headerCount > 0) { + for (int i = findHeaderIndexOrNext(positionStart); i != -1 && i < headerCount; i++) { + mHeaderPositions.set(i, mHeaderPositions.get(i) + itemCount); + } + } + + // Add new headers. + for (int i = positionStart; i < positionStart + itemCount; i++) { + if (mAdapter.isStickyHeader(i)) { + int headerIndex = findHeaderIndexOrNext(i); + if (headerIndex != -1) { + mHeaderPositions.add(headerIndex, i); + } else { + mHeaderPositions.add(i); + } + } + } + } + + @Override public void onItemRangeRemoved(int positionStart, int itemCount) { + int headerCount = mHeaderPositions.size(); + if (headerCount > 0) { + // Remove headers. + for (int i = positionStart + itemCount - 1; i >= positionStart; i--) { + int index = findHeaderIndex(i); + if (index != -1) { + mHeaderPositions.remove(index); + headerCount--; + } + } + + // Remove sticky header immediately if the entry it represents has been removed. A layout will follow. + if (mStickyHeader != null && !mHeaderPositions.contains(mStickyHeaderPosition)) { + scrapStickyHeader(null); + } + + // Shift headers below up. + for (int i = findHeaderIndexOrNext(positionStart + itemCount); i != -1 && i < headerCount; + i++) { + mHeaderPositions.set(i, mHeaderPositions.get(i) - itemCount); + } + } + } + + @Override public void onItemRangeMoved(int fromPosition, int toPosition, int itemCount) { + // Shift moved headers by toPosition - fromPosition. + // Shift headers in-between by -itemCount (reverse if upwards). + int headerCount = mHeaderPositions.size(); + if (headerCount > 0) { + if (fromPosition < toPosition) { + for (int i = findHeaderIndexOrNext(fromPosition); i != -1 && i < headerCount; i++) { + int headerPos = mHeaderPositions.get(i); + if (headerPos >= fromPosition && headerPos < fromPosition + itemCount) { + mHeaderPositions.set(i, headerPos - (toPosition - fromPosition)); + sortHeaderAtIndex(i); + } else if (headerPos >= fromPosition + itemCount && headerPos <= toPosition) { + mHeaderPositions.set(i, headerPos - itemCount); + sortHeaderAtIndex(i); + } else { + break; + } + } + } else { + for (int i = findHeaderIndexOrNext(toPosition); i != -1 && i < headerCount; i++) { + int headerPos = mHeaderPositions.get(i); + if (headerPos >= fromPosition && headerPos < fromPosition + itemCount) { + mHeaderPositions.set(i, headerPos + (toPosition - fromPosition)); + sortHeaderAtIndex(i); + } else if (headerPos >= toPosition && headerPos <= fromPosition) { + mHeaderPositions.set(i, headerPos + itemCount); + sortHeaderAtIndex(i); + } else { + break; + } + } + } + } + } + + private void sortHeaderAtIndex(int index) { + int headerPos = mHeaderPositions.remove(index); + int headerIndex = findHeaderIndexOrNext(headerPos); + if (headerIndex != -1) { + mHeaderPositions.add(headerIndex, headerPos); + } else { + mHeaderPositions.add(headerPos); + } + } + } + + public static class SavedState implements Parcelable { + private Parcelable superState; + private int pendingScrollPosition; + private int pendingScrollOffset; + + public SavedState() { + } + + public SavedState(Parcel in) { + superState = in.readParcelable(SavedState.class.getClassLoader()); + pendingScrollPosition = in.readInt(); + pendingScrollOffset = in.readInt(); + } + + @Override public int describeContents() { + return 0; + } + + @Override public void writeToParcel(@NonNull Parcel dest, int flags) { + dest.writeParcelable(superState, flags); + dest.writeInt(pendingScrollPosition); + dest.writeInt(pendingScrollOffset); + } + + 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]; + } + }; + } +} From b7408bd011204ff78ede056a0a2e45c30d7eefd6 Mon Sep 17 00:00:00 2001 From: Fitz Date: Mon, 17 Oct 2022 20:01:51 +0800 Subject: [PATCH 2/8] add FlapStickyHeaderLinearLayoutManager --- .../me/yifeiyuan/flapdev/ConfigMenuView.kt | 3 + .../FlapStickyHeaderLinearLayoutManager.kt | 636 ++++++++++++++++++ .../flapdev/testcases/BaseTestcaseFragment.kt | 18 +- app/src/main/res/layout/debug_menu.xml | 6 + .../java/me/yifeiyuan/flap/FlapAdapter.kt | 16 + 5 files changed, 678 insertions(+), 1 deletion(-) create mode 100644 app/src/main/java/me/yifeiyuan/flapdev/FlapStickyHeaderLinearLayoutManager.kt diff --git a/app/src/main/java/me/yifeiyuan/flapdev/ConfigMenuView.kt b/app/src/main/java/me/yifeiyuan/flapdev/ConfigMenuView.kt index 8f897bf1..90413a35 100644 --- a/app/src/main/java/me/yifeiyuan/flapdev/ConfigMenuView.kt +++ b/app/src/main/java/me/yifeiyuan/flapdev/ConfigMenuView.kt @@ -47,6 +47,9 @@ class ConfigMenuView : FrameLayout { R.id.indexedStaggered -> { callback?.onLayoutManagerChanged(3) } + R.id.stickyHeader->{ + callback?.onLayoutManagerChanged(4) + } } } diff --git a/app/src/main/java/me/yifeiyuan/flapdev/FlapStickyHeaderLinearLayoutManager.kt b/app/src/main/java/me/yifeiyuan/flapdev/FlapStickyHeaderLinearLayoutManager.kt new file mode 100644 index 00000000..a20b5406 --- /dev/null +++ b/app/src/main/java/me/yifeiyuan/flapdev/FlapStickyHeaderLinearLayoutManager.kt @@ -0,0 +1,636 @@ +package me.yifeiyuan.flapdev + +import android.content.Context +import android.graphics.PointF +import android.os.Build +import android.os.Parcelable +import android.view.View +import android.view.ViewGroup +import android.view.ViewTreeObserver +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.RecyclerView +import kotlinx.android.parcel.Parcelize +import me.yifeiyuan.flap.FlapAdapter + +/** + * Created by 程序亦非猿 on 2022/10/17. + * + * 基于 epoxy v5.0.0 StickyHeaderLinearLayoutManager 修改 + * @since 3.1.8 + */ +class FlapStickyHeaderLinearLayoutManager @JvmOverloads constructor( + context: Context, + orientation: Int = RecyclerView.VERTICAL, + reverseLayout: Boolean = false +) : LinearLayoutManager(context, orientation, reverseLayout) { + + private var adapter: FlapAdapter? = null + + // Translation for header + private var translationX: Float = 0f + private var translationY: Float = 0f + + // Header positions for the currently displayed list and their observer. + private val headerPositions = mutableListOf() + private val headerPositionsObserver = HeaderPositionsAdapterDataObserver() + + // Sticky header's ViewHolder and dirty state. + private var stickyHeader: View? = null + private var stickyHeaderPosition = RecyclerView.NO_POSITION + + // Save / Restore scroll state + private var scrollPosition = RecyclerView.NO_POSITION + private var scrollOffset = 0 + + override fun onAttachedToWindow(recyclerView: RecyclerView) { + super.onAttachedToWindow(recyclerView) + setAdapter(recyclerView.adapter) + } + + override fun onAdapterChanged(oldAdapter: RecyclerView.Adapter<*>?, newAdapter: RecyclerView.Adapter<*>?) { + super.onAdapterChanged(oldAdapter, newAdapter) + setAdapter(newAdapter) + } + + @Suppress("UNCHECKED_CAST") + private fun setAdapter(newAdapter: RecyclerView.Adapter<*>?) { + adapter?.unregisterAdapterDataObserver(headerPositionsObserver) + if (newAdapter is FlapAdapter) { + adapter = newAdapter + adapter?.registerAdapterDataObserver(headerPositionsObserver) + headerPositionsObserver.onChanged() + } else { + adapter = null + headerPositions.clear() + } + } + + override fun onSaveInstanceState(): Parcelable? { + return super.onSaveInstanceState()?.let { + SavedState( + superState = it, + scrollPosition = scrollPosition, + scrollOffset = scrollOffset + ) + } + } + + override fun onRestoreInstanceState(state: Parcelable) { + (state as SavedState).let { + scrollPosition = it.scrollPosition + scrollOffset = it.scrollOffset + super.onRestoreInstanceState(it.superState) + } + } + + override fun scrollVerticallyBy(dy: Int, recycler: RecyclerView.Recycler, state: RecyclerView.State): Int { + val scrolled = restoreView { super.scrollVerticallyBy(dy, recycler, state) } + if (scrolled != 0) { + updateStickyHeader(recycler, false) + } + return scrolled + } + + override fun scrollHorizontallyBy(dx: Int, recycler: RecyclerView.Recycler, state: RecyclerView.State): Int { + val scrolled = restoreView { super.scrollHorizontallyBy(dx, recycler, state) } + if (scrolled != 0) { + updateStickyHeader(recycler, false) + } + return scrolled + } + + override fun onLayoutChildren(recycler: RecyclerView.Recycler, state: RecyclerView.State) { + try { + restoreView { super.onLayoutChildren(recycler, state) } + if (!state.isPreLayout) { + updateStickyHeader(recycler, true) + } + } catch (e: Exception) { + e.printStackTrace() + } + } + + override fun scrollToPosition(position: Int) = scrollToPositionWithOffset(position, INVALID_OFFSET) + + override fun scrollToPositionWithOffset(position: Int, offset: Int) = scrollToPositionWithOffset(position, offset, true) + + private fun scrollToPositionWithOffset(position: Int, offset: Int, adjustForStickyHeader: Boolean) { + // Reset pending scroll. + setScrollState(RecyclerView.NO_POSITION, INVALID_OFFSET) + + // Adjusting is disabled. + if (!adjustForStickyHeader) { + super.scrollToPositionWithOffset(position, offset) + return + } + + // There is no header above or the position is a header. + val headerIndex = findHeaderIndexOrBefore(position) + if (headerIndex == -1 || findHeaderIndex(position) != -1) { + super.scrollToPositionWithOffset(position, offset) + return + } + + // The position is right below a header, scroll to the header. + if (findHeaderIndex(position - 1) != -1) { + super.scrollToPositionWithOffset(position - 1, offset) + return + } + + // Current sticky header is the same as at the position. Adjust the scroll offset and reset pending scroll. + if (stickyHeader != null && headerIndex == findHeaderIndex(stickyHeaderPosition)) { + val adjustedOffset = (if (offset != INVALID_OFFSET) offset else 0) + stickyHeader!!.height + super.scrollToPositionWithOffset(position, adjustedOffset) + return + } + + // Remember this position and offset and scroll to it to trigger creating the sticky header. + setScrollState(position, offset) + super.scrollToPositionWithOffset(position, offset) + } + + //region Computation + // Mainly [RecyclerView] functionality by removing sticky header from calculations + + override fun computeVerticalScrollExtent(state: RecyclerView.State): Int = restoreView { super.computeVerticalScrollExtent(state) } + + override fun computeVerticalScrollOffset(state: RecyclerView.State): Int = restoreView { super.computeVerticalScrollOffset(state) } + + override fun computeVerticalScrollRange(state: RecyclerView.State): Int = restoreView { super.computeVerticalScrollRange(state) } + + override fun computeHorizontalScrollExtent(state: RecyclerView.State): Int = restoreView { super.computeHorizontalScrollExtent(state) } + + override fun computeHorizontalScrollOffset(state: RecyclerView.State): Int = restoreView { super.computeHorizontalScrollOffset(state) } + + override fun computeHorizontalScrollRange(state: RecyclerView.State): Int = restoreView { super.computeHorizontalScrollRange(state) } + + override fun computeScrollVectorForPosition(targetPosition: Int): PointF? = restoreView { super.computeScrollVectorForPosition(targetPosition) } + + override fun onFocusSearchFailed( + focused: View, + focusDirection: Int, + recycler: RecyclerView.Recycler, + state: RecyclerView.State + ): View? = restoreView { super.onFocusSearchFailed(focused, focusDirection, recycler, state) } + + /** + * Perform the [operation] without the sticky header view by + * detaching the view -> performing operation -> detaching the view. + */ + private fun restoreView(operation: () -> T): T { + stickyHeader?.let(this::detachView) + val result = operation() + stickyHeader?.let(this::attachView) + return result + } + + //endregion + + /** + * Offsets the vertical location of the sticky header relative to the its default position. + */ + fun setStickyHeaderTranslationY(translationY: Float) { + this.translationY = translationY + requestLayout() + } + + /** + * Offsets the horizontal location of the sticky header relative to the its default position. + */ + fun setStickyHeaderTranslationX(translationX: Float) { + this.translationX = translationX + requestLayout() + } + + /** + * Returns true if `view` is the current sticky header. + */ + fun isStickyHeader(view: View): Boolean = view === stickyHeader + + /** + * Updates the sticky header state (creation, binding, display), to be called whenever there's a layout or scroll + */ + private fun updateStickyHeader(recycler: RecyclerView.Recycler, layout: Boolean) { + val headerCount = headerPositions.size + val childCount = childCount + if (headerCount > 0 && childCount > 0) { + // Find first valid child. + var anchorView: View? = null + var anchorIndex = -1 + var anchorPos = -1 + for (i in 0 until childCount) { + val child = getChildAt(i) + val params = child!!.layoutParams as RecyclerView.LayoutParams + if (isViewValidAnchor(child, params)) { + anchorView = child + anchorIndex = i + anchorPos = params.viewAdapterPosition + break + } + } + if (anchorView != null && anchorPos != -1) { + val headerIndex = findHeaderIndexOrBefore(anchorPos) + val headerPos = if (headerIndex != -1) headerPositions[headerIndex] else -1 + val nextHeaderPos = if (headerCount > headerIndex + 1) headerPositions[headerIndex + 1] else -1 + + // Show sticky header if: + // - There's one to show; + // - It's on the edge or it's not the anchor view; + // - Isn't followed by another sticky header; + if (headerPos != -1 && + (headerPos != anchorPos || isViewOnBoundary(anchorView)) && + nextHeaderPos != headerPos + 1 + ) { + // 1. Ensure existing sticky header, if any, is of correct type. + if (stickyHeader != null && getItemViewType(stickyHeader!!) != adapter?.getItemViewType(headerPos)) { + // A sticky header was shown before but is not of the correct type. Scrap it. + scrapStickyHeader(recycler) + } + + // 2. Ensure sticky header is created, if absent, or bound, if being laid out or the position changed. + if (stickyHeader == null) createStickyHeader(recycler, headerPos) + // 3. Bind the sticky header + if (layout || getPosition(stickyHeader!!) != headerPos) bindStickyHeader(recycler, stickyHeader!!, headerPos) + + // 4. Draw the sticky header using translation values which depend on orientation, direction and + // position of the next header view. + stickyHeader?.let { + val nextHeaderView: View? = if (nextHeaderPos != -1) { + val nextHeaderView = getChildAt(anchorIndex + (nextHeaderPos - anchorPos)) + // The header view itself is added to the RecyclerView. Discard it if it comes up. + if (nextHeaderView === stickyHeader) null else nextHeaderView + } else null + it.translationX = getX(it, nextHeaderView) + it.translationY = getY(it, nextHeaderView) + } + return + } + } + } + + if (stickyHeader != null) { + scrapStickyHeader(recycler) + } + } + + /** + * Creates [RecyclerView.ViewHolder] for [position], including measure / layout, and assigns it to + * [stickyHeader]. + */ + private fun createStickyHeader(recycler: RecyclerView.Recycler, position: Int) { + val stickyHeader = recycler.getViewForPosition(position) + + // Setup sticky header if the adapter requires it. +// adapter?.setupStickyHeaderView(stickyHeader) + + // Add sticky header as a child view, to be detached / reattached whenever LinearLayoutManager#fill() is called, + // which happens on layout and scroll (see overrides). + addView(stickyHeader) + measureAndLayout(stickyHeader) + + // Ignore sticky header, as it's fully managed by this LayoutManager. + ignoreView(stickyHeader) + + this.stickyHeader = stickyHeader + this.stickyHeaderPosition = position + } + + /** + * Binds the [stickyHeader] for the given [position]. + */ + private fun bindStickyHeader(recycler: RecyclerView.Recycler, stickyHeader: View, position: Int) { + // Bind the sticky header. + recycler.bindViewToPosition(stickyHeader, position) + stickyHeaderPosition = position + measureAndLayout(stickyHeader) + + // If we have a pending scroll wait until the end of layout and scroll again. + if (scrollPosition != RecyclerView.NO_POSITION) { + stickyHeader.viewTreeObserver.addOnGlobalLayoutListener(object : ViewTreeObserver.OnGlobalLayoutListener { + override fun onGlobalLayout() { + if (Build.VERSION.SDK_INT < 16) stickyHeader.viewTreeObserver.removeGlobalOnLayoutListener(this) + else stickyHeader.viewTreeObserver.removeOnGlobalLayoutListener(this) + if (scrollPosition != RecyclerView.NO_POSITION) { + scrollToPositionWithOffset(scrollPosition, scrollOffset) + setScrollState(RecyclerView.NO_POSITION, INVALID_OFFSET) + } + } + }) + } + } + + /** + * Measures and lays out [stickyHeader]. + */ + private fun measureAndLayout(stickyHeader: View) { + measureChildWithMargins(stickyHeader, 0, 0) + when (orientation) { + VERTICAL -> stickyHeader.layout(paddingLeft, 0, width - paddingRight, stickyHeader.measuredHeight) + else -> stickyHeader.layout(0, paddingTop, stickyHeader.measuredWidth, height - paddingBottom) + } + } + + /** + * Returns [stickyHeader] to the [RecyclerView]'s [RecyclerView.RecycledViewPool], assigning it + * to `null`. + * + * @param recycler If passed, the sticky header will be returned to the recycled view pool. + */ + private fun scrapStickyHeader(recycler: RecyclerView.Recycler?) { + val stickyHeader = stickyHeader ?: return + this.stickyHeader = null + this.stickyHeaderPosition = RecyclerView.NO_POSITION + + // Revert translation values. + stickyHeader.translationX = 0f + stickyHeader.translationY = 0f + + // Teardown holder if the adapter requires it. +// adapter?.teardownStickyHeaderView(stickyHeader) + + // Stop ignoring sticky header so that it can be recycled. + stopIgnoringView(stickyHeader) + + // Remove and recycle sticky header. + removeView(stickyHeader) + recycler?.recycleView(stickyHeader) + } + + /** + * Returns true when `view` is a valid anchor, ie. the first view to be valid and visible. + */ + private fun isViewValidAnchor(view: View, params: RecyclerView.LayoutParams): Boolean { + return when { + !params.isItemRemoved && !params.isViewInvalid -> when (orientation) { + VERTICAL -> when { + reverseLayout -> view.top + view.translationY <= height + translationY + else -> view.bottom - view.translationY >= translationY + } + else -> when { + reverseLayout -> view.left + view.translationX <= width + translationX + else -> view.right - view.translationX >= translationX + } + } + else -> false + } + } + + /** + * Returns true when the `view` is at the edge of the parent [RecyclerView]. + */ + private fun isViewOnBoundary(view: View): Boolean { + return when (orientation) { + VERTICAL -> when { + reverseLayout -> view.bottom - view.translationY > height + translationY + else -> view.top + view.translationY < translationY + } + else -> when { + reverseLayout -> view.right - view.translationX > width + translationX + else -> view.left + view.translationX < translationX + } + } + } + + /** + * Returns the position in the Y axis to position the header appropriately, depending on orientation, direction and + * [android.R.attr.clipToPadding]. + */ + private fun getY(headerView: View, nextHeaderView: View?): Float { + when (orientation) { + VERTICAL -> { + var y = translationY + if (reverseLayout) { + y += (height - headerView.height).toFloat() + } + if (nextHeaderView != null) { + val bottomMargin = (nextHeaderView.layoutParams as? ViewGroup.MarginLayoutParams)?.bottomMargin ?: 0 + val topMargin = (nextHeaderView.layoutParams as? ViewGroup.MarginLayoutParams)?.topMargin ?: 0 + y = when { + reverseLayout -> (nextHeaderView.bottom + bottomMargin).toFloat().coerceAtLeast(y) + else -> (nextHeaderView.top - topMargin - headerView.height).toFloat().coerceAtMost(y) + } + } + return y + } + else -> return translationY + } + } + + /** + * Returns the position in the X axis to position the header appropriately, depending on orientation, direction and + * [android.R.attr.clipToPadding]. + */ + private fun getX(headerView: View, nextHeaderView: View?): Float { + when (orientation) { + HORIZONTAL -> { + var x = translationX + if (reverseLayout) { + x += (width - headerView.width).toFloat() + } + if (nextHeaderView != null) { + val leftMargin = (nextHeaderView.layoutParams as? ViewGroup.MarginLayoutParams)?.leftMargin ?: 0 + val rightMargin = (nextHeaderView.layoutParams as? ViewGroup.MarginLayoutParams)?.rightMargin ?: 0 + x = when { + reverseLayout -> (nextHeaderView.right + rightMargin).toFloat().coerceAtLeast(x) + else -> (nextHeaderView.left - leftMargin - headerView.width).toFloat().coerceAtMost(x) + } + } + return x + } + else -> return translationX + } + } + + /** + * Finds the header index of `position` in `headerPositions`. + */ + private fun findHeaderIndex(position: Int): Int { + var low = 0 + var high = headerPositions.size - 1 + while (low <= high) { + val middle = (low + high) / 2 + when { + headerPositions[middle] > position -> high = middle - 1 + headerPositions[middle] < position -> low = middle + 1 + else -> return middle + } + } + return -1 + } + + /** + * Finds the header index of `position` or the one before it in `headerPositions`. + */ + private fun findHeaderIndexOrBefore(position: Int): Int { + var low = 0 + var high = headerPositions.size - 1 + while (low <= high) { + val middle = (low + high) / 2 + when { + headerPositions[middle] > position -> high = middle - 1 + middle < headerPositions.size - 1 && headerPositions[middle + 1] <= position -> low = middle + 1 + else -> return middle + } + } + return -1 + } + + /** + * Finds the header index of `position` or the one next to it in `headerPositions`. + */ + private fun findHeaderIndexOrNext(position: Int): Int { + var low = 0 + var high = headerPositions.size - 1 + while (low <= high) { + val middle = (low + high) / 2 + when { + middle > 0 && headerPositions[middle - 1] >= position -> high = middle - 1 + headerPositions[middle] < position -> low = middle + 1 + else -> return middle + } + } + return -1 + } + + private fun setScrollState(position: Int, offset: Int) { + scrollPosition = position + scrollOffset = offset + } + + /** + * Save / restore existing [RecyclerView] state and + * scrolling position and offset. + */ + @Parcelize + data class SavedState( + val superState: Parcelable, + val scrollPosition: Int, + val scrollOffset: Int + ) : Parcelable + + /** + * Handles header positions while adapter changes occur. + * + * This is used in detriment of [RecyclerView.LayoutManager]'s callbacks to control when they're received. + */ + private inner class HeaderPositionsAdapterDataObserver : RecyclerView.AdapterDataObserver() { + override fun onChanged() { + // There's no hint at what changed, so go through the adapter. + headerPositions.clear() + val itemCount = adapter?.itemCount ?: 0 + for (i in 0 until itemCount) { + val isSticky = adapter?.isStickyHeader(i) ?: false + if (isSticky) { + headerPositions.add(i) + } + } + + // Remove sticky header immediately if the entry it represents has been removed. A layout will follow. + if (stickyHeader != null && !headerPositions.contains(stickyHeaderPosition)) { + scrapStickyHeader(null) + } + } + + override fun onItemRangeInserted(positionStart: Int, itemCount: Int) { + // Shift headers below down. + val headerCount = headerPositions.size + if (headerCount > 0) { + var i = findHeaderIndexOrNext(positionStart) + while (i != -1 && i < headerCount) { + headerPositions[i] = headerPositions[i] + itemCount + i++ + } + } + + // Add new headers. + for (i in positionStart until positionStart + itemCount) { + val isSticky = adapter?.isStickyHeader(i) ?: false + if (isSticky) { + val headerIndex = findHeaderIndexOrNext(i) + if (headerIndex != -1) { + headerPositions.add(headerIndex, i) + } else { + headerPositions.add(i) + } + } + } + } + + override fun onItemRangeRemoved(positionStart: Int, itemCount: Int) { + var headerCount = headerPositions.size + if (headerCount > 0) { + // Remove headers. + for (i in positionStart + itemCount - 1 downTo positionStart) { + val index = findHeaderIndex(i) + if (index != -1) { + headerPositions.removeAt(index) + headerCount-- + } + } + + // Remove sticky header immediately if the entry it represents has been removed. A layout will follow. + if (stickyHeader != null && !headerPositions.contains(stickyHeaderPosition)) { + scrapStickyHeader(null) + } + + // Shift headers below up. + var i = findHeaderIndexOrNext(positionStart + itemCount) + while (i != -1 && i < headerCount) { + headerPositions[i] = headerPositions[i] - itemCount + i++ + } + } + } + + override fun onItemRangeMoved(fromPosition: Int, toPosition: Int, itemCount: Int) { + // Shift moved headers by toPosition - fromPosition. + // Shift headers in-between by -itemCount (reverse if upwards). + val headerCount = headerPositions.size + if (headerCount > 0) { + if (fromPosition < toPosition) { + var i = findHeaderIndexOrNext(fromPosition) + while (i != -1 && i < headerCount) { + val headerPos = headerPositions[i] + if (headerPos >= fromPosition && headerPos < fromPosition + itemCount) { + headerPositions[i] = headerPos - (toPosition - fromPosition) + sortHeaderAtIndex(i) + } else if (headerPos >= fromPosition + itemCount && headerPos <= toPosition) { + headerPositions[i] = headerPos - itemCount + sortHeaderAtIndex(i) + } else { + break + } + i++ + } + } else { + var i = findHeaderIndexOrNext(toPosition) + loop@ while (i != -1 && i < headerCount) { + val headerPos = headerPositions[i] + when { + headerPos >= fromPosition && headerPos < fromPosition + itemCount -> { + headerPositions[i] = headerPos + (toPosition - fromPosition) + sortHeaderAtIndex(i) + } + headerPos in toPosition..fromPosition -> { + headerPositions[i] = headerPos + itemCount + sortHeaderAtIndex(i) + } + else -> break@loop + } + i++ + } + } + } + } + + private fun sortHeaderAtIndex(index: Int) { + val headerPos = headerPositions.removeAt(index) + val headerIndex = findHeaderIndexOrNext(headerPos) + if (headerIndex != -1) { + headerPositions.add(headerIndex, headerPos) + } else { + headerPositions.add(headerPos) + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/me/yifeiyuan/flapdev/testcases/BaseTestcaseFragment.kt b/app/src/main/java/me/yifeiyuan/flapdev/testcases/BaseTestcaseFragment.kt index d45d0396..8fbb0eb7 100644 --- a/app/src/main/java/me/yifeiyuan/flapdev/testcases/BaseTestcaseFragment.kt +++ b/app/src/main/java/me/yifeiyuan/flapdev/testcases/BaseTestcaseFragment.kt @@ -16,7 +16,6 @@ import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.StaggeredGridLayoutManager import androidx.swiperefreshlayout.widget.SwipeRefreshLayout import com.google.android.material.bottomsheet.BottomSheetDialog -import kotlinx.android.synthetic.main.debug_menu.* import me.yifeiyuan.flap.FlapAdapter import me.yifeiyuan.flap.decoration.LinearItemDecoration import me.yifeiyuan.flap.decoration.LinearSpaceItemDecoration @@ -56,6 +55,8 @@ open class BaseTestcaseFragment : Fragment(), Scrollable, IMenuView { lateinit var indexedStaggeredGridLayoutManager: FlapIndexedStaggeredGridLayoutManager lateinit var currentLayoutManager: RecyclerView.LayoutManager + lateinit var stickyHeaderLinearLayoutManager: FlapStickyHeaderLinearLayoutManager + open var useFlapRecyclerView = false override fun onAttach(context: Context) { @@ -139,6 +140,8 @@ open class BaseTestcaseFragment : Fragment(), Scrollable, IMenuView { adapter.registerAdapterService(TestService::class.java) + adapter.setupStickyHeaderHandler { position, itemData -> position % 2 == 0 } + initLayoutManagers() initItemDecorations() @@ -173,6 +176,8 @@ open class BaseTestcaseFragment : Fragment(), Scrollable, IMenuView { staggeredGridLayoutManager = FlapStaggeredGridLayoutManager(2) + stickyHeaderLinearLayoutManager = FlapStickyHeaderLinearLayoutManager(requireActivity()) + currentLayoutManager = linearLayoutManager } @@ -306,6 +311,14 @@ open class BaseTestcaseFragment : Fragment(), Scrollable, IMenuView { currentLayoutManager = indexedStaggeredGridLayoutManager recyclerView.layoutManager = currentLayoutManager } + 4 -> { + recyclerView.removeItemDecoration(currentItemDecoration) + currentItemDecoration = linearItemDecoration + recyclerView.addItemDecoration(currentItemDecoration) + recyclerView.invalidateItemDecorations() + currentLayoutManager = stickyHeaderLinearLayoutManager + recyclerView.layoutManager = currentLayoutManager + } } } @@ -336,6 +349,9 @@ open class BaseTestcaseFragment : Fragment(), Scrollable, IMenuView { is FlapIndexedStaggeredGridLayoutManager -> { (currentLayoutManager as FlapIndexedStaggeredGridLayoutManager).orientation = orientation } + is FlapStickyHeaderLinearLayoutManager -> { + (currentLayoutManager as FlapStickyHeaderLinearLayoutManager).orientation = orientation + } } recyclerView.invalidateItemDecorations() diff --git a/app/src/main/res/layout/debug_menu.xml b/app/src/main/res/layout/debug_menu.xml index fe0f19aa..327a46ef 100644 --- a/app/src/main/res/layout/debug_menu.xml +++ b/app/src/main/res/layout/debug_menu.xml @@ -124,6 +124,12 @@ android:layout_height="wrap_content" android:layout_marginLeft="12dp" android:text="Indexed" /> + Boolean)? = null + fun setupStickyHeaderHandler(block: (position: Int, itemData: Any) -> Boolean) { + stickyHeaderHandler = block + } + + // delegation.isStickyHeader(position, getItemData(position)) + fun isStickyHeader(position: Int): Boolean { + return stickyHeaderHandler?.invoke(position, getItemData(position)) ?: false + } } \ No newline at end of file From bae5fc95e5d1537e8e3d98108045cfbf93eb90a7 Mon Sep 17 00:00:00 2001 From: Fitz Date: Mon, 17 Oct 2022 20:08:19 +0800 Subject: [PATCH 3/8] =?UTF-8?q?=E7=A7=BB=E5=8A=A8=20FlapStickyHeaderLinear?= =?UTF-8?q?LayoutManager?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- flap/build.gradle | 1 + .../flap/widget}/FlapStickyHeaderLinearLayoutManager.kt | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) rename {app/src/main/java/me/yifeiyuan/flapdev => flap/src/main/java/me/yifeiyuan/flap/widget}/FlapStickyHeaderLinearLayoutManager.kt (99%) diff --git a/flap/build.gradle b/flap/build.gradle index 12fe2502..1f450ccc 100644 --- a/flap/build.gradle +++ b/flap/build.gradle @@ -1,5 +1,6 @@ apply plugin: 'com.android.library' apply plugin: 'kotlin-android' +apply plugin: 'kotlin-parcelize' apply plugin: 'com.github.dcendents.android-maven' diff --git a/app/src/main/java/me/yifeiyuan/flapdev/FlapStickyHeaderLinearLayoutManager.kt b/flap/src/main/java/me/yifeiyuan/flap/widget/FlapStickyHeaderLinearLayoutManager.kt similarity index 99% rename from app/src/main/java/me/yifeiyuan/flapdev/FlapStickyHeaderLinearLayoutManager.kt rename to flap/src/main/java/me/yifeiyuan/flap/widget/FlapStickyHeaderLinearLayoutManager.kt index a20b5406..529712d6 100644 --- a/app/src/main/java/me/yifeiyuan/flapdev/FlapStickyHeaderLinearLayoutManager.kt +++ b/flap/src/main/java/me/yifeiyuan/flap/widget/FlapStickyHeaderLinearLayoutManager.kt @@ -1,4 +1,4 @@ -package me.yifeiyuan.flapdev +package me.yifeiyuan.flap.widget import android.content.Context import android.graphics.PointF From 81e5ca7a8e924b65137ee4ce27389e7dc33552b2 Mon Sep 17 00:00:00 2001 From: Fitz Date: Mon, 17 Oct 2022 21:05:32 +0800 Subject: [PATCH 4/8] =?UTF-8?q?=E9=80=82=E9=85=8D=20HeaderFooterAdapter.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../me/yifeiyuan/flapdev/ConfigMenuView.kt | 10 ++- .../flapdev/testcases/MultiTypeTestcase.kt | 1 + app/src/main/res/layout/debug_menu.xml | 71 ++++++++++++------- .../java/me/yifeiyuan/flap/FlapAdapter.kt | 6 +- .../yifeiyuan/flap/ext/HeaderFooterAdapter.kt | 12 +++- .../FlapStickyHeaderLinearLayoutManager.kt | 13 ++-- .../flap/widget/FlapStickyHeaders.kt | 10 +++ 7 files changed, 85 insertions(+), 38 deletions(-) create mode 100644 flap/src/main/java/me/yifeiyuan/flap/widget/FlapStickyHeaders.kt diff --git a/app/src/main/java/me/yifeiyuan/flapdev/ConfigMenuView.kt b/app/src/main/java/me/yifeiyuan/flapdev/ConfigMenuView.kt index 90413a35..f23c1171 100644 --- a/app/src/main/java/me/yifeiyuan/flapdev/ConfigMenuView.kt +++ b/app/src/main/java/me/yifeiyuan/flapdev/ConfigMenuView.kt @@ -2,6 +2,7 @@ package me.yifeiyuan.flapdev import android.content.Context import android.view.LayoutInflater +import android.view.View import android.widget.FrameLayout import android.widget.SeekBar import androidx.recyclerview.widget.RecyclerView @@ -33,8 +34,8 @@ class ConfigMenuView : FrameLayout { //1 Grid //2 Staggered //3 IndexedStaggered - binding.layoutManagerGroup.setOnCheckedChangeListener { group, checkedId -> - when (checkedId) { + val layoutManagerClickListener = OnClickListener { v -> + when (v.id) { R.id.linear -> { callback?.onLayoutManagerChanged(0) } @@ -52,6 +53,11 @@ class ConfigMenuView : FrameLayout { } } } + binding.linear.setOnClickListener(layoutManagerClickListener) + binding.grid.setOnClickListener(layoutManagerClickListener) + binding.staggered.setOnClickListener(layoutManagerClickListener) + binding.indexedStaggered.setOnClickListener(layoutManagerClickListener) + binding.stickyHeader.setOnClickListener(layoutManagerClickListener) binding.clearAll.setOnClickListener { callback?.onClearAllData() diff --git a/app/src/main/java/me/yifeiyuan/flapdev/testcases/MultiTypeTestcase.kt b/app/src/main/java/me/yifeiyuan/flapdev/testcases/MultiTypeTestcase.kt index e7fd8953..615bf169 100644 --- a/app/src/main/java/me/yifeiyuan/flapdev/testcases/MultiTypeTestcase.kt +++ b/app/src/main/java/me/yifeiyuan/flapdev/testcases/MultiTypeTestcase.kt @@ -60,6 +60,7 @@ class MultiTypeTestcase : BaseTestcaseFragment() { } } .onlyOnce(false) + .suppressLayout(true) .withEmptyViewHelper(adapter.emptyViewHelper) .show() diff --git a/app/src/main/res/layout/debug_menu.xml b/app/src/main/res/layout/debug_menu.xml index 327a46ef..554993b8 100644 --- a/app/src/main/res/layout/debug_menu.xml +++ b/app/src/main/res/layout/debug_menu.xml @@ -31,13 +31,12 @@ android:layout_width="0dp" android:layout_height="wrap_content" app:constraint_referenced_ids="clearAll,resetData,addTopData,appendData,addZeroHeightData" + app:flow_horizontalBias="0" + app:flow_horizontalStyle="packed" app:flow_wrapMode="chain" app:layout_constraintLeft_toLeftOf="parent" app:layout_constraintRight_toRightOf="parent" - app:layout_constraintTop_toTopOf="parent" - app:flow_horizontalBias="0" - app:flow_horizontalStyle="packed" - /> + app:layout_constraintTop_toTopOf="parent" />