diff --git a/app/src/main/java/me/yifeiyuan/flapdev/ConfigMenuView.kt b/app/src/main/java/me/yifeiyuan/flapdev/ConfigMenuView.kt index 8f897bf1..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) } @@ -47,8 +48,16 @@ class ConfigMenuView : FrameLayout { R.id.indexedStaggered -> { callback?.onLayoutManagerChanged(3) } + R.id.stickyHeader->{ + callback?.onLayoutManagerChanged(4) + } } } + 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/FlapApplication.kt b/app/src/main/java/me/yifeiyuan/flapdev/FlapApplication.kt index 9800d363..bd843ba0 100644 --- a/app/src/main/java/me/yifeiyuan/flapdev/FlapApplication.kt +++ b/app/src/main/java/me/yifeiyuan/flapdev/FlapApplication.kt @@ -3,7 +3,10 @@ package me.yifeiyuan.flapdev import android.app.Application import android.util.Log import android.widget.Toast +import androidx.core.view.get import androidx.multidex.MultiDexApplication +import androidx.recyclerview.widget.RecyclerView +import androidx.viewpager2.widget.ViewPager2 import me.yifeiyuan.flap.Flap import me.yifeiyuan.flap.apt.delegates.* import me.yifeiyuan.flap.dsl.adapterHook @@ -17,6 +20,12 @@ import me.yifeiyuan.flapdev.components.* class FlapApplication : MultiDexApplication() { companion object { + + val ViewPager2.recyclerView: RecyclerView + get() { + return this[0] as RecyclerView + } + var application: Application? = null set(value) { field = value diff --git a/app/src/main/java/me/yifeiyuan/flapdev/SingleScrollDirection.kt b/app/src/main/java/me/yifeiyuan/flapdev/SingleScrollDirection.kt new file mode 100644 index 00000000..48afa341 --- /dev/null +++ b/app/src/main/java/me/yifeiyuan/flapdev/SingleScrollDirection.kt @@ -0,0 +1,72 @@ +package me.yifeiyuan.flapdev + +import android.view.MotionEvent +import androidx.recyclerview.widget.RecyclerView +import java.lang.Math.abs + +/** + * Created by 程序亦非猿 on 2022/10/18. + */ + +fun RecyclerView.enforceSingleScrollDirection() { + val enforcer = SingleScrollDirectionEnforcer() + addOnItemTouchListener(enforcer) + addOnScrollListener(enforcer) +} + +private class SingleScrollDirectionEnforcer : RecyclerView.OnScrollListener(), RecyclerView.OnItemTouchListener { + + private var scrollState = RecyclerView.SCROLL_STATE_IDLE + private var scrollPointerId = -1 + private var initialTouchX = 0 + private var initialTouchY = 0 + private var dx = 0 + private var dy = 0 + + override fun onInterceptTouchEvent(rv: RecyclerView, e: MotionEvent): Boolean { + when (e.actionMasked) { + MotionEvent.ACTION_DOWN -> { + scrollPointerId = e.getPointerId(0) + initialTouchX = (e.x + 0.5f).toInt() + initialTouchY = (e.y + 0.5f).toInt() + } + MotionEvent.ACTION_POINTER_DOWN -> { + val actionIndex = e.actionIndex + scrollPointerId = e.getPointerId(actionIndex) + initialTouchX = (e.getX(actionIndex) + 0.5f).toInt() + initialTouchY = (e.getY(actionIndex) + 0.5f).toInt() + } + MotionEvent.ACTION_MOVE -> { + val index = e.findPointerIndex(scrollPointerId) + if (index >= 0 && scrollState != RecyclerView.SCROLL_STATE_DRAGGING) { + val x = (e.getX(index) + 0.5f).toInt() + val y = (e.getY(index) + 0.5f).toInt() + dx = x - initialTouchX + dy = y - initialTouchY + } + } + } + return false + } + + override fun onTouchEvent(rv: RecyclerView, e: MotionEvent) {} + + override fun onRequestDisallowInterceptTouchEvent(disallowIntercept: Boolean) {} + + override fun onScrollStateChanged(recyclerView: RecyclerView, newState: Int) { + val oldState = scrollState + scrollState = newState + if (oldState == RecyclerView.SCROLL_STATE_IDLE && newState == RecyclerView.SCROLL_STATE_DRAGGING) { + recyclerView.layoutManager?.let { layoutManager -> + val canScrollHorizontally = layoutManager.canScrollHorizontally() + val canScrollVertically = layoutManager.canScrollVertically() + if (canScrollHorizontally != canScrollVertically) { + if ((canScrollHorizontally && abs(dy) > abs(dx)) + || (canScrollVertically && abs(dx) > abs(dy))) { + recyclerView.stopScroll() + } + } + } + } + } +} \ No newline at end of file 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]; + } + }; + } +} diff --git a/app/src/main/java/me/yifeiyuan/flapdev/components/Banner.kt b/app/src/main/java/me/yifeiyuan/flapdev/components/Banner.kt index a1b27ff1..643ef0f5 100644 --- a/app/src/main/java/me/yifeiyuan/flapdev/components/Banner.kt +++ b/app/src/main/java/me/yifeiyuan/flapdev/components/Banner.kt @@ -4,10 +4,12 @@ import android.util.Log import android.widget.ImageView import androidx.viewpager2.widget.ViewPager2 import com.bumptech.glide.Glide +import kotlinx.android.synthetic.main.fragment_base_case.view.* import me.yifeiyuan.flap.FlapAdapter import me.yifeiyuan.flap.dsl.adapterDelegate import me.yifeiyuan.flapdev.FlapApplication.Companion.toast import me.yifeiyuan.flapdev.R +import me.yifeiyuan.flapdev.enforceSingleScrollDirection /** * Created by 程序亦非猿 on 2022/9/11. @@ -28,6 +30,7 @@ fun createBannerAdapterDelegate() = adapterDelegate(R.layout.compon dragEnable = false val viewPager2 = findViewById(R.id.banner) + val bannerAdapter = FlapAdapter().apply { registerAdapterDelegate(bannerImageDelegate()) } 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/java/me/yifeiyuan/flapdev/testcases/HeaderFooterTestcase.kt b/app/src/main/java/me/yifeiyuan/flapdev/testcases/HeaderFooterTestcase.kt index a918ba94..195092e8 100644 --- a/app/src/main/java/me/yifeiyuan/flapdev/testcases/HeaderFooterTestcase.kt +++ b/app/src/main/java/me/yifeiyuan/flapdev/testcases/HeaderFooterTestcase.kt @@ -30,9 +30,17 @@ class HeaderFooterTestcase : BaseTestcaseFragment() { val headerView = LayoutInflater.from(activity).inflate(R.layout.header_layout, null, false) headerFooterAdapter.setupHeaderView(headerView) + headerView.setOnClickListener { + Log.d(TAG, "onInit: HeaderView clicked") + } + val footerView = LayoutInflater.from(activity).inflate(R.layout.footer_layout, null, false) headerFooterAdapter.setupFooterView(footerView) + footerView.setOnClickListener { + Log.d(TAG, "onInit: FooterView clicked") + } + //需要处理偏移量 adapter.doOnItemClick { recyclerView, childView, position -> Log.d(TAG, "doOnItemClick called with: childView = $childView, position = $position") @@ -45,10 +53,13 @@ class HeaderFooterTestcase : BaseTestcaseFragment() { toast("点击了 position = $position,是 Footer!") return@doOnItemClick } - val realPosition = if (position == 0) position else position - headerFooterAdapter.getHeaderCount() + val realPosition = headerFooterAdapter.getFixedPosition(position) +// val realPosition = if (position == 0) position else position - headerFooterAdapter.getHeaderCount() toast("点击了 position = $position,model=${adapter.getItemData(realPosition)}") } + switchLayoutManager(1) + recyclerView.adapter = headerFooterAdapter SwipeDragHelper(headerFooterAdapter) @@ -81,14 +92,13 @@ class HeaderFooterTestcase : BaseTestcaseFragment() { } .attachToRecyclerView(recyclerView) - // 需要处理 SpanSize - gridLayoutManager.spanSizeLookup = object : GridLayoutManager.SpanSizeLookup() { - override fun getSpanSize(position: Int): Int { - return if (headerFooterAdapter.isHeaderOrFooter(position)) gridLayoutManager.spanCount else 1 - } - } + // 如果默认不是 GridLayoutManager,则需要处理 SpanSize +// gridLayoutManager.spanSizeLookup = object : GridLayoutManager.SpanSizeLookup() { +// override fun getSpanSize(position: Int): Int { +// return if (headerFooterAdapter.isHeaderOrFooter(position)) gridLayoutManager.spanCount else 1 +// } +// } - switchLayoutManager(0) } override fun createRefreshData(size: Int): MutableList { 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/java/me/yifeiyuan/flapdev/testcases/ViewPager2Testcase.kt b/app/src/main/java/me/yifeiyuan/flapdev/testcases/ViewPager2Testcase.kt index 73a453af..916ec833 100644 --- a/app/src/main/java/me/yifeiyuan/flapdev/testcases/ViewPager2Testcase.kt +++ b/app/src/main/java/me/yifeiyuan/flapdev/testcases/ViewPager2Testcase.kt @@ -3,13 +3,17 @@ package me.yifeiyuan.flapdev.testcases import android.os.Bundle import android.view.* import android.widget.Toast +import androidx.core.view.get import androidx.fragment.app.Fragment +import androidx.recyclerview.widget.RecyclerView import androidx.viewpager2.widget.ViewPager2 +import kotlinx.android.synthetic.main.fragment_base_case.view.* import me.yifeiyuan.flap.FlapAdapter import me.yifeiyuan.flap.ext.doOnCreateViewHolderEnd import me.yifeiyuan.flapdev.R import me.yifeiyuan.flapdev.Scrollable import me.yifeiyuan.flapdev.components.SimpleTextModel +import me.yifeiyuan.flapdev.enforceSingleScrollDirection import java.util.* /** diff --git a/app/src/main/res/layout/debug_menu.xml b/app/src/main/res/layout/debug_menu.xml index fe0f19aa..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" />