| /* |
| * 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(); |
| } |
| } |
| } |
| } |