blob: d6481a9b57c3d84b96f8f0ad52f4913dec784b7d [file] [log] [blame]
/*
* Copyright (C) 2021 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.android.launcher3.views;
import static android.view.View.MeasureSpec.EXACTLY;
import static android.view.View.MeasureSpec.makeMeasureSpec;
import static com.android.launcher3.anim.AnimatorListeners.forEndCallback;
import android.animation.Animator;
import android.animation.ObjectAnimator;
import android.content.Context;
import android.content.res.TypedArray;
import android.util.AttributeSet;
import android.util.FloatProperty;
import android.view.MotionEvent;
import android.view.View;
import android.view.ViewGroup;
import android.widget.LinearLayout;
import androidx.annotation.NonNull;
import androidx.recyclerview.widget.RecyclerView;
import com.android.launcher3.R;
/**
* A {@link LinearLayout} container which allows scrolling parts of its content based on the
* scroll of a different view. Views which are marked as sticky are not scrolled, giving the
* illusion of a sticky header.
*/
public class StickyHeaderLayout extends LinearLayout implements
RecyclerView.OnChildAttachStateChangeListener {
private static final FloatProperty<StickyHeaderLayout> SCROLL_OFFSET =
new FloatProperty<StickyHeaderLayout>("scrollAnimOffset") {
@Override
public void setValue(StickyHeaderLayout view, float offset) {
view.mScrollOffset = offset;
view.updateHeaderScroll();
}
@Override
public Float get(StickyHeaderLayout view) {
return view.mScrollOffset;
}
};
private static final MotionEventProxyMethod INTERCEPT_PROXY = ViewGroup::onInterceptTouchEvent;
private static final MotionEventProxyMethod TOUCH_PROXY = ViewGroup::onTouchEvent;
private RecyclerView mCurrentRecyclerView;
private EmptySpaceView mCurrentEmptySpaceView;
private float mLastScroll = 0;
private float mScrollOffset = 0;
private Animator mOffsetAnimator;
private boolean mShouldForwardToRecyclerView = false;
private int mHeaderHeight;
public StickyHeaderLayout(Context context) {
this(context, /* attrs= */ null);
}
public StickyHeaderLayout(Context context, AttributeSet attrs) {
this(context, attrs, /* defStyleAttr= */ 0);
}
public StickyHeaderLayout(Context context, AttributeSet attrs, int defStyleAttr) {
this(context, attrs, defStyleAttr, /* defStyleRes= */ 0);
}
public StickyHeaderLayout(Context context, AttributeSet attrs, int defStyleAttr,
int defStyleRes) {
super(context, attrs, defStyleAttr, defStyleRes);
}
/**
* Sets the recycler view, this sticky header should track
*/
public void setCurrentRecyclerView(RecyclerView currentRecyclerView) {
boolean animateReset = mCurrentRecyclerView != null;
if (mCurrentRecyclerView != null) {
mCurrentRecyclerView.removeOnChildAttachStateChangeListener(this);
}
mCurrentRecyclerView = currentRecyclerView;
mCurrentRecyclerView.addOnChildAttachStateChangeListener(this);
findCurrentEmptyView();
reset(animateReset);
}
public int getHeaderHeight() {
return mHeaderHeight;
}
private void updateHeaderScroll() {
mLastScroll = getCurrentScroll();
int count = getChildCount();
for (int i = 0; i < count; i++) {
View child = getChildAt(i);
MyLayoutParams lp = (MyLayoutParams) child.getLayoutParams();
child.setTranslationY(Math.max(mLastScroll, lp.scrollLimit));
}
}
private float getCurrentScroll() {
return mScrollOffset + (mCurrentEmptySpaceView == null ? 0 : mCurrentEmptySpaceView.getY());
}
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
super.onMeasure(widthMeasureSpec, heightMeasureSpec);
mHeaderHeight = getMeasuredHeight();
if (mCurrentEmptySpaceView != null) {
mCurrentEmptySpaceView.setFixedHeight(mHeaderHeight);
}
}
/** Resets any previous view translation. */
public void reset(boolean animate) {
if (mOffsetAnimator != null) {
mOffsetAnimator.cancel();
mOffsetAnimator = null;
}
mScrollOffset = 0;
if (!animate) {
updateHeaderScroll();
} else {
float startValue = mLastScroll - getCurrentScroll();
mOffsetAnimator = ObjectAnimator.ofFloat(this, SCROLL_OFFSET, startValue, 0);
mOffsetAnimator.addListener(forEndCallback(() -> mOffsetAnimator = null));
mOffsetAnimator.start();
}
}
@Override
public boolean onInterceptTouchEvent(MotionEvent event) {
return (mShouldForwardToRecyclerView = proxyMotionEvent(event, INTERCEPT_PROXY))
|| super.onInterceptTouchEvent(event);
}
@Override
public boolean onTouchEvent(MotionEvent event) {
return mShouldForwardToRecyclerView && proxyMotionEvent(event, TOUCH_PROXY)
|| super.onTouchEvent(event);
}
private boolean proxyMotionEvent(MotionEvent event, MotionEventProxyMethod method) {
float dx = mCurrentRecyclerView.getLeft() - getLeft();
float dy = mCurrentRecyclerView.getTop() - getTop();
event.offsetLocation(dx, dy);
try {
return method.proxyEvent(mCurrentRecyclerView, event);
} finally {
event.offsetLocation(-dx, -dy);
}
}
@Override
public void onChildViewAttachedToWindow(@NonNull View view) {
if (view instanceof EmptySpaceView) {
findCurrentEmptyView();
}
}
@Override
public void onChildViewDetachedFromWindow(@NonNull View view) {
if (view == mCurrentEmptySpaceView) {
findCurrentEmptyView();
}
}
private void findCurrentEmptyView() {
if (mCurrentEmptySpaceView != null) {
mCurrentEmptySpaceView.setOnYChangeCallback(null);
mCurrentEmptySpaceView = null;
}
int childCount = mCurrentRecyclerView.getChildCount();
for (int i = 0; i < childCount; i++) {
View view = mCurrentRecyclerView.getChildAt(i);
if (view instanceof EmptySpaceView) {
mCurrentEmptySpaceView = (EmptySpaceView) view;
mCurrentEmptySpaceView.setFixedHeight(getHeaderHeight());
mCurrentEmptySpaceView.setOnYChangeCallback(this::updateHeaderScroll);
return;
}
}
}
@Override
protected void onLayout(boolean changed, int l, int t, int r, int b) {
super.onLayout(changed, l, t, r, b);
// Update various stick parameters
int count = getChildCount();
int stickyHeaderHeight = 0;
for (int i = 0; i < count; i++) {
View v = getChildAt(i);
MyLayoutParams lp = (MyLayoutParams) v.getLayoutParams();
if (lp.sticky) {
lp.scrollLimit = -v.getTop() + stickyHeaderHeight;
stickyHeaderHeight += v.getHeight();
} else {
lp.scrollLimit = Integer.MIN_VALUE;
}
}
updateHeaderScroll();
}
@Override
protected LayoutParams generateDefaultLayoutParams() {
return new MyLayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT);
}
@Override
protected LayoutParams generateLayoutParams(ViewGroup.LayoutParams lp) {
return new MyLayoutParams(lp.width, lp.height);
}
@Override
public LayoutParams generateLayoutParams(AttributeSet attrs) {
return new MyLayoutParams(getContext(), attrs);
}
@Override
protected boolean checkLayoutParams(ViewGroup.LayoutParams p) {
return p instanceof MyLayoutParams;
}
private static class MyLayoutParams extends LayoutParams {
public final boolean sticky;
public int scrollLimit;
MyLayoutParams(int width, int height) {
super(width, height);
sticky = false;
}
MyLayoutParams(Context c, AttributeSet attrs) {
super(c, attrs);
TypedArray a = c.obtainStyledAttributes(attrs, R.styleable.StickyScroller_Layout);
sticky = a.getBoolean(R.styleable.StickyScroller_Layout_layout_sticky, false);
a.recycle();
}
}
private interface MotionEventProxyMethod {
boolean proxyEvent(ViewGroup view, MotionEvent event);
}
/**
* Empty view which allows listening for 'Y' changes
*/
public static class EmptySpaceView extends View {
private Runnable mOnYChangeCallback;
private int mHeight = 0;
public EmptySpaceView(Context context) {
super(context);
animate().setUpdateListener(v -> notifyYChanged());
}
/**
* Sets the height for the empty view
* @return true if the height changed, false otherwise
*/
public boolean setFixedHeight(int height) {
if (mHeight != height) {
mHeight = height;
requestLayout();
return true;
}
return false;
}
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
super.onMeasure(widthMeasureSpec, makeMeasureSpec(mHeight, EXACTLY));
}
public void setOnYChangeCallback(Runnable callback) {
mOnYChangeCallback = callback;
}
@Override
protected void onLayout(boolean changed, int left, int top, int right, int bottom) {
super.onLayout(changed, left, top, right, bottom);
notifyYChanged();
}
@Override
public void offsetTopAndBottom(int offset) {
super.offsetTopAndBottom(offset);
notifyYChanged();
}
@Override
public void setTranslationY(float translationY) {
super.setTranslationY(translationY);
notifyYChanged();
}
private void notifyYChanged() {
if (mOnYChangeCallback != null) {
mOnYChangeCallback.run();
}
}
}
}