package com.zulip.android.viewholders.stickyheaders;
// modified from https://github.com/bgogetap/StickyHeaders/blob/master/stickyheaders/src/main/java/com/brandongogetap/stickyheaders/StickyHeaderPositioner.java
import android.os.Build;
import android.support.annotation.Nullable;
import android.support.annotation.Px;
import android.support.annotation.VisibleForTesting;
import android.support.v7.widget.LinearLayoutManager;
import android.support.v7.widget.RecyclerView;
import android.view.View;
import android.view.ViewGroup;
import android.view.ViewGroup.MarginLayoutParams;
import android.view.ViewTreeObserver;
import com.zulip.android.viewholders.stickyheaders.interfaces.StickyHeaderListener;
import java.util.List;
import java.util.Map;
final class GetStickyHeaderPosition {
private static final int INVALID_POSITION = -1;
private final RecyclerView recyclerView;
private final boolean checkMargins;
private final boolean fallbackReset;
private View currentHeader;
private int lastBoundPosition = INVALID_POSITION;
private List<Integer> headerPositions;
private int orientation;
private boolean dirty;
private boolean updateCurrentHeader;
private RecyclerView.ViewHolder currentViewHolder;
@Nullable
private StickyHeaderListener listener;
GetStickyHeaderPosition(RecyclerView recyclerView) {
this.recyclerView = recyclerView;
checkMargins = recyclerViewHasPadding();
if (recyclerView.getAdapter() != null) {
fallbackReset = false;
recyclerView.getAdapter().registerAdapterDataObserver(
new RecyclerView.AdapterDataObserver() {
@Override
public void onChanged() {
updateCurrentHeader = true;
}
});
} else {
fallbackReset = true;
}
}
void setHeaderPositions(List<Integer> headerPositions) {
this.headerPositions = headerPositions;
}
void updateHeaderState(int firstVisiblePosition, Map<Integer, View> visibleHeaders,
RetrieveHeaderView retrieveHeaderView) {
int headerPositionToShow = getHeaderPositionToShow(
firstVisiblePosition, visibleHeaders.get(firstVisiblePosition));
View headerToCopy = visibleHeaders.get(headerPositionToShow);
if (headerPositionToShow != lastBoundPosition || updateCurrentHeader) {
if (headerPositionToShow == INVALID_POSITION) {
dirty = true;
safeDetachHeader();
lastBoundPosition = INVALID_POSITION;
} else {
// We don't want to attach yet if header view is not at edge
if (checkMargins && headerAwayFromEdge(headerToCopy)) return;
RecyclerView.ViewHolder viewHolder =
retrieveHeaderView.getViewHolderForPosition(headerPositionToShow);
attachHeader(viewHolder, headerPositionToShow);
lastBoundPosition = headerPositionToShow;
}
} else if (checkMargins) {
/*
This could still be our firstVisiblePosition even if another view is visible above it.
See `#getHeaderPositionToShow` for explanation.
*/
if (headerAwayFromEdge(headerToCopy)) {
detachHeader(lastBoundPosition);
lastBoundPosition = INVALID_POSITION;
}
}
checkHeaderPositions(visibleHeaders);
}
// This checks visible headers and their positions to determine if the sticky header needs
// to be offset. In reality, only the header following the sticky header is checked. Some
// optimization may be possible here (not storing all visible headers in map).
private void checkHeaderPositions(final Map<Integer, View> visibleHeaders) {
if (currentHeader == null) return;
// This can happen after configuration changes.
if (currentHeader.getHeight() == 0) {
waitForLayoutAndRetry(visibleHeaders);
return;
}
boolean reset = false;
for (Map.Entry<Integer, View> entry : visibleHeaders.entrySet()) {
if (entry.getKey() == lastBoundPosition) {
reset = true;
continue;
}
View nextHeader = entry.getValue();
reset = offsetHeader(nextHeader) == -1;
break;
}
if (reset) resetTranslation();
currentHeader.setVisibility(View.VISIBLE);
}
private float offsetHeader(View nextHeader) {
boolean shouldOffsetHeader = shouldOffsetHeader(nextHeader);
float offset = -1;
if (shouldOffsetHeader) {
if (orientation == LinearLayoutManager.VERTICAL) {
offset = -(currentHeader.getHeight() - nextHeader.getY());
currentHeader.setTranslationY(offset);
} else {
offset = -(currentHeader.getWidth() - nextHeader.getX());
currentHeader.setTranslationX(offset);
}
}
return offset;
}
private boolean shouldOffsetHeader(View nextHeader) {
if (orientation == LinearLayoutManager.VERTICAL) {
return nextHeader.getY() < currentHeader.getHeight();
} else {
return nextHeader.getX() < currentHeader.getWidth();
}
}
private void resetTranslation() {
if (orientation == LinearLayoutManager.VERTICAL) {
currentHeader.setTranslationY(0);
} else {
currentHeader.setTranslationX(0);
}
}
/**
* In case of padding, first visible position may not be accurate.
* <p>
* Example: RecyclerView has padding of 10dp. With clipToPadding set to false, a visible view
* above the 10dp threshold will not be recognized as firstVisiblePosition by the LayoutManager.
* <p>
* To remedy this, we are checking if the firstVisiblePosition (according to the LayoutManager)
* is a header (headerForPosition will not be null). If it is, we check it's Y. If #getY is
* greater than 0 then we know it is actually not the firstVisiblePosition, and return the
* preceding header position (if available).
*/
private int getHeaderPositionToShow(int firstVisiblePosition, @Nullable View headerForPosition) {
int headerPositionToShow = INVALID_POSITION;
if (headerIsOffset(headerForPosition)) {
int offsetHeaderIndex = headerPositions.indexOf(firstVisiblePosition);
if (offsetHeaderIndex > 0) {
return headerPositions.get(offsetHeaderIndex - 1);
}
}
for (Integer headerPosition : headerPositions) {
if (headerPosition <= firstVisiblePosition) {
headerPositionToShow = headerPosition;
} else {
break;
}
}
return headerPositionToShow;
}
private boolean headerIsOffset(View headerForPosition) {
return headerForPosition != null && (orientation == LinearLayoutManager.VERTICAL ? headerForPosition.getY() > 0 : headerForPosition.getX() > 0);
}
@VisibleForTesting
private void attachHeader(RecyclerView.ViewHolder viewHolder, int headerPosition) {
if (currentViewHolder == viewHolder) {
callDetach(lastBoundPosition);
//noinspection unchecked
recyclerView.getAdapter().onBindViewHolder(currentViewHolder, headerPosition);
callAttach(headerPosition);
updateCurrentHeader = false;
return;
}
detachHeader(lastBoundPosition);
this.currentViewHolder = viewHolder;
//noinspection unchecked
recyclerView.getAdapter().onBindViewHolder(currentViewHolder, headerPosition);
this.currentHeader = currentViewHolder.itemView;
callAttach(headerPosition);
// Set to Invisible until we position it in #checkHeaderPositions.
currentHeader.setVisibility(View.INVISIBLE);
//currentHeader.setId(R.id.header_view);
getRecyclerParent().addView(currentHeader);
if (checkMargins) {
updateLayoutParams(currentHeader);
}
dirty = false;
}
private void detachHeader(int position) {
if (currentHeader != null) {
getRecyclerParent().removeView(currentHeader);
callDetach(position);
currentHeader = null;
currentViewHolder = null;
}
}
private void callAttach(int position) {
if (listener != null) {
listener.headerAttached(position);
}
}
private void callDetach(int position) {
if (listener != null) {
listener.headerDetached(position);
}
}
/**
* Adds margins to left/right (or top/bottom in horizontal orientation)
* <p>
* Top padding (or left padding in horizontal orientation) with clipToPadding = true is not
* supported. If you need to offset the top (or left in horizontal orientation) and do not
* want scrolling children to be visible, use margins.
*/
private void updateLayoutParams(View currentHeader) {
MarginLayoutParams params = (MarginLayoutParams) currentHeader.getLayoutParams();
matchMarginsToPadding(params);
}
private void matchMarginsToPadding(MarginLayoutParams layoutParams) {
@Px int leftMargin = orientation == LinearLayoutManager.VERTICAL ?
recyclerView.getPaddingLeft() : 0;
@Px int topMargin = orientation == LinearLayoutManager.VERTICAL ?
0 : recyclerView.getPaddingTop();
@Px int rightMargin = orientation == LinearLayoutManager.VERTICAL ?
recyclerView.getPaddingRight() : 0;
layoutParams.setMargins(leftMargin, topMargin, rightMargin, 0);
}
private boolean headerAwayFromEdge(View headerToCopy) {
return headerToCopy != null && (orientation == LinearLayoutManager.VERTICAL ? headerToCopy.getY() > 0 : headerToCopy.getX() > 0);
}
void reset(int orientation, int firstVisiblePosition) {
this.orientation = orientation;
// Don't reset/detach if same header position is to be attached
if (getHeaderPositionToShow(firstVisiblePosition, null) == lastBoundPosition) {
return;
}
if (fallbackReset) {
lastBoundPosition = INVALID_POSITION;
}
}
private boolean recyclerViewHasPadding() {
return recyclerView.getPaddingLeft() > 0
|| recyclerView.getPaddingRight() > 0
|| recyclerView.getPaddingTop() > 0;
}
/**
* @return parent view of recyclerView
*/
private ViewGroup getRecyclerParent() {
return (ViewGroup) recyclerView.getParent();
}
private void waitForLayoutAndRetry(final Map<Integer, View> visibleHeaders) {
currentHeader.getViewTreeObserver().addOnGlobalLayoutListener(
new ViewTreeObserver.OnGlobalLayoutListener() {
@Override
public void onGlobalLayout() {
// If header was removed during layout
if (currentHeader == null) return;
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN) {
currentHeader.getViewTreeObserver().removeOnGlobalLayoutListener(this);
} else {
//noinspection deprecation
currentHeader.getViewTreeObserver().removeGlobalOnLayoutListener(this);
}
getRecyclerParent().requestLayout();
checkHeaderPositions(visibleHeaders);
}
});
}
/**
* Detaching while {@link StickyLayoutManager} is laying out children can cause an inconsistent
* state in the child count variable in {@link android.widget.FrameLayout} layoutChildren method
*/
private void safeDetachHeader() {
final int cachedPosition = lastBoundPosition;
getRecyclerParent().post(new Runnable() {
@Override
public void run() {
if (dirty) {
detachHeader(cachedPosition);
}
}
});
}
/**
* set listener
* useful to get position when it float's on top
*
* @param listener listener which should be set
*/
void setListener(@Nullable StickyHeaderListener listener) {
this.listener = listener;
}
}