• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
1 /*
2  * Copyright (C) 2021 The Android Open Source Project
3  *
4  * Licensed under the Apache License, Version 2.0 (the "License");
5  * you may not use this file except in compliance with the License.
6  * You may obtain a copy of the License at
7  *
8  *      http://www.apache.org/licenses/LICENSE-2.0
9  *
10  * Unless required by applicable law or agreed to in writing, software
11  * distributed under the License is distributed on an "AS IS" BASIS,
12  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13  * See the License for the specific language governing permissions and
14  * limitations under the License.
15  */
16 package com.android.launcher3.views;
17 
18 import static android.view.View.MeasureSpec.EXACTLY;
19 import static android.view.View.MeasureSpec.makeMeasureSpec;
20 
21 import static com.android.launcher3.anim.AnimatorListeners.forEndCallback;
22 
23 import android.animation.Animator;
24 import android.animation.ObjectAnimator;
25 import android.content.Context;
26 import android.content.res.TypedArray;
27 import android.util.AttributeSet;
28 import android.util.FloatProperty;
29 import android.view.MotionEvent;
30 import android.view.View;
31 import android.view.ViewGroup;
32 import android.widget.LinearLayout;
33 
34 import androidx.annotation.NonNull;
35 import androidx.recyclerview.widget.RecyclerView;
36 
37 import com.android.launcher3.R;
38 
39 /**
40  * A {@link LinearLayout} container which allows scrolling parts of its content based on the
41  * scroll of a different view. Views which are marked as sticky are not scrolled, giving the
42  * illusion of a sticky header.
43  */
44 public class StickyHeaderLayout extends LinearLayout implements
45         RecyclerView.OnChildAttachStateChangeListener {
46 
47     private static final FloatProperty<StickyHeaderLayout> SCROLL_OFFSET =
48             new FloatProperty<StickyHeaderLayout>("scrollAnimOffset") {
49                 @Override
50                 public void setValue(StickyHeaderLayout view, float offset) {
51                     view.mScrollOffset = offset;
52                     view.updateHeaderScroll();
53                 }
54 
55                 @Override
56                 public Float get(StickyHeaderLayout view) {
57                     return view.mScrollOffset;
58                 }
59             };
60 
61     private static final MotionEventProxyMethod INTERCEPT_PROXY = ViewGroup::onInterceptTouchEvent;
62     private static final MotionEventProxyMethod TOUCH_PROXY = ViewGroup::onTouchEvent;
63 
64     private RecyclerView mCurrentRecyclerView;
65     private EmptySpaceView mCurrentEmptySpaceView;
66 
67     private float mLastScroll = 0;
68     private float mScrollOffset = 0;
69     private Animator mOffsetAnimator;
70 
71     private boolean mShouldForwardToRecyclerView = false;
72     private int mHeaderHeight;
73 
StickyHeaderLayout(Context context)74     public StickyHeaderLayout(Context context) {
75         this(context, /* attrs= */ null);
76     }
77 
StickyHeaderLayout(Context context, AttributeSet attrs)78     public StickyHeaderLayout(Context context, AttributeSet attrs) {
79         this(context, attrs, /* defStyleAttr= */ 0);
80     }
81 
StickyHeaderLayout(Context context, AttributeSet attrs, int defStyleAttr)82     public StickyHeaderLayout(Context context, AttributeSet attrs, int defStyleAttr) {
83         this(context, attrs, defStyleAttr, /* defStyleRes= */ 0);
84     }
85 
StickyHeaderLayout(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes)86     public StickyHeaderLayout(Context context, AttributeSet attrs, int defStyleAttr,
87             int defStyleRes) {
88         super(context, attrs, defStyleAttr, defStyleRes);
89     }
90 
91     /**
92      * Sets the recycler view, this sticky header should track
93      */
setCurrentRecyclerView(RecyclerView currentRecyclerView)94     public void setCurrentRecyclerView(RecyclerView currentRecyclerView) {
95         boolean animateReset = mCurrentRecyclerView != null;
96         if (mCurrentRecyclerView != null) {
97             mCurrentRecyclerView.removeOnChildAttachStateChangeListener(this);
98         }
99         mCurrentRecyclerView = currentRecyclerView;
100         mCurrentRecyclerView.addOnChildAttachStateChangeListener(this);
101         findCurrentEmptyView();
102         reset(animateReset);
103     }
104 
getHeaderHeight()105     public int getHeaderHeight() {
106         return mHeaderHeight;
107     }
108 
updateHeaderScroll()109     private void updateHeaderScroll() {
110         mLastScroll = getCurrentScroll();
111         int count = getChildCount();
112         for (int i = 0; i < count; i++) {
113             View child = getChildAt(i);
114             MyLayoutParams lp = (MyLayoutParams) child.getLayoutParams();
115             child.setTranslationY(Math.max(mLastScroll, lp.scrollLimit));
116         }
117     }
118 
getCurrentScroll()119     private float getCurrentScroll() {
120         return mScrollOffset + (mCurrentEmptySpaceView == null ? 0 : mCurrentEmptySpaceView.getY());
121     }
122 
123     @Override
onMeasure(int widthMeasureSpec, int heightMeasureSpec)124     protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
125         super.onMeasure(widthMeasureSpec, heightMeasureSpec);
126 
127         mHeaderHeight = getMeasuredHeight();
128         if (mCurrentEmptySpaceView != null) {
129             mCurrentEmptySpaceView.setFixedHeight(mHeaderHeight);
130         }
131     }
132 
133     /** Resets any previous view translation. */
reset(boolean animate)134     public void reset(boolean animate) {
135         if (mOffsetAnimator != null) {
136             mOffsetAnimator.cancel();
137             mOffsetAnimator = null;
138         }
139 
140         mScrollOffset = 0;
141         if (!animate) {
142             updateHeaderScroll();
143         } else {
144             float startValue = mLastScroll - getCurrentScroll();
145             mOffsetAnimator = ObjectAnimator.ofFloat(this, SCROLL_OFFSET, startValue, 0);
146             mOffsetAnimator.addListener(forEndCallback(() -> mOffsetAnimator = null));
147             mOffsetAnimator.start();
148         }
149     }
150 
151     @Override
onInterceptTouchEvent(MotionEvent event)152     public boolean onInterceptTouchEvent(MotionEvent event) {
153         return (mShouldForwardToRecyclerView = proxyMotionEvent(event, INTERCEPT_PROXY))
154                 || super.onInterceptTouchEvent(event);
155     }
156 
157     @Override
onTouchEvent(MotionEvent event)158     public boolean onTouchEvent(MotionEvent event) {
159         return mShouldForwardToRecyclerView && proxyMotionEvent(event, TOUCH_PROXY)
160                 || super.onTouchEvent(event);
161     }
162 
proxyMotionEvent(MotionEvent event, MotionEventProxyMethod method)163     private boolean proxyMotionEvent(MotionEvent event, MotionEventProxyMethod method) {
164         float dx = mCurrentRecyclerView.getLeft() - getLeft();
165         float dy = mCurrentRecyclerView.getTop() - getTop();
166         event.offsetLocation(dx, dy);
167         try {
168             return method.proxyEvent(mCurrentRecyclerView, event);
169         } finally {
170             event.offsetLocation(-dx, -dy);
171         }
172     }
173 
174     @Override
onChildViewAttachedToWindow(@onNull View view)175     public void onChildViewAttachedToWindow(@NonNull View view) {
176         if (view instanceof EmptySpaceView) {
177             findCurrentEmptyView();
178         }
179     }
180 
181     @Override
onChildViewDetachedFromWindow(@onNull View view)182     public void onChildViewDetachedFromWindow(@NonNull View view) {
183         if (view == mCurrentEmptySpaceView) {
184             findCurrentEmptyView();
185         }
186     }
187 
findCurrentEmptyView()188     private void findCurrentEmptyView() {
189         if (mCurrentEmptySpaceView != null) {
190             mCurrentEmptySpaceView.setOnYChangeCallback(null);
191             mCurrentEmptySpaceView = null;
192         }
193         int childCount = mCurrentRecyclerView.getChildCount();
194         for (int i = 0; i < childCount; i++) {
195             View view = mCurrentRecyclerView.getChildAt(i);
196             if (view instanceof EmptySpaceView) {
197                 mCurrentEmptySpaceView = (EmptySpaceView) view;
198                 mCurrentEmptySpaceView.setFixedHeight(getHeaderHeight());
199                 mCurrentEmptySpaceView.setOnYChangeCallback(this::updateHeaderScroll);
200                 return;
201             }
202         }
203     }
204 
205     @Override
onLayout(boolean changed, int l, int t, int r, int b)206     protected void onLayout(boolean changed, int l, int t, int r, int b) {
207         super.onLayout(changed, l, t, r, b);
208 
209         // Update various stick parameters
210         int count = getChildCount();
211         int stickyHeaderHeight = 0;
212         for (int i = 0; i < count; i++) {
213             View v = getChildAt(i);
214             MyLayoutParams lp = (MyLayoutParams) v.getLayoutParams();
215             if (lp.sticky) {
216                 lp.scrollLimit = -v.getTop() + stickyHeaderHeight;
217                 stickyHeaderHeight += v.getHeight();
218             } else {
219                 lp.scrollLimit = Integer.MIN_VALUE;
220             }
221         }
222         updateHeaderScroll();
223     }
224 
225     @Override
generateDefaultLayoutParams()226     protected LayoutParams generateDefaultLayoutParams() {
227         return new MyLayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT);
228     }
229 
230     @Override
generateLayoutParams(ViewGroup.LayoutParams lp)231     protected LayoutParams generateLayoutParams(ViewGroup.LayoutParams lp) {
232         return new MyLayoutParams(lp.width, lp.height);
233     }
234 
235     @Override
generateLayoutParams(AttributeSet attrs)236     public LayoutParams generateLayoutParams(AttributeSet attrs) {
237         return new MyLayoutParams(getContext(), attrs);
238     }
239 
240     @Override
checkLayoutParams(ViewGroup.LayoutParams p)241     protected boolean checkLayoutParams(ViewGroup.LayoutParams p) {
242         return p instanceof MyLayoutParams;
243     }
244 
245     private static class MyLayoutParams extends LayoutParams {
246 
247         public final boolean sticky;
248         public int scrollLimit;
249 
MyLayoutParams(int width, int height)250         MyLayoutParams(int width, int height) {
251             super(width, height);
252             sticky = false;
253         }
254 
MyLayoutParams(Context c, AttributeSet attrs)255         MyLayoutParams(Context c, AttributeSet attrs) {
256             super(c, attrs);
257             TypedArray a = c.obtainStyledAttributes(attrs, R.styleable.StickyScroller_Layout);
258             sticky = a.getBoolean(R.styleable.StickyScroller_Layout_layout_sticky, false);
259             a.recycle();
260         }
261     }
262 
263     private interface MotionEventProxyMethod {
264 
proxyEvent(ViewGroup view, MotionEvent event)265         boolean proxyEvent(ViewGroup view, MotionEvent event);
266     }
267 
268     /**
269      * Empty view which allows listening for 'Y' changes
270      */
271     public static class EmptySpaceView extends View {
272 
273         private Runnable mOnYChangeCallback;
274         private int mHeight = 0;
275 
EmptySpaceView(Context context)276         public EmptySpaceView(Context context) {
277             super(context);
278             animate().setUpdateListener(v -> notifyYChanged());
279         }
280 
281         /**
282          * Sets the height for the empty view
283          * @return true if the height changed, false otherwise
284          */
setFixedHeight(int height)285         public boolean setFixedHeight(int height) {
286             if (mHeight != height) {
287                 mHeight = height;
288                 requestLayout();
289                 return true;
290             }
291             return false;
292         }
293 
294         @Override
onMeasure(int widthMeasureSpec, int heightMeasureSpec)295         protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
296             super.onMeasure(widthMeasureSpec, makeMeasureSpec(mHeight, EXACTLY));
297         }
298 
setOnYChangeCallback(Runnable callback)299         public void setOnYChangeCallback(Runnable callback) {
300             mOnYChangeCallback = callback;
301         }
302 
303         @Override
onLayout(boolean changed, int left, int top, int right, int bottom)304         protected void onLayout(boolean changed, int left, int top, int right, int bottom) {
305             super.onLayout(changed, left, top, right, bottom);
306             notifyYChanged();
307         }
308 
309         @Override
offsetTopAndBottom(int offset)310         public void offsetTopAndBottom(int offset) {
311             super.offsetTopAndBottom(offset);
312             notifyYChanged();
313         }
314 
315         @Override
setTranslationY(float translationY)316         public void setTranslationY(float translationY) {
317             super.setTranslationY(translationY);
318             notifyYChanged();
319         }
320 
notifyYChanged()321         private void notifyYChanged() {
322             if (mOnYChangeCallback != null) {
323                 mOnYChangeCallback.run();
324             }
325         }
326     }
327 }
328