• 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 import java.util.ArrayList;
40 import java.util.List;
41 
42 /**
43  * A {@link LinearLayout} container which allows scrolling parts of its content based on the
44  * scroll of a different view. Views which are marked as sticky are not scrolled, giving the
45  * illusion of a sticky header.
46  */
47 public class StickyHeaderLayout extends LinearLayout implements
48         RecyclerView.OnChildAttachStateChangeListener {
49 
50     private static final FloatProperty<StickyHeaderLayout> SCROLL_OFFSET =
51             new FloatProperty<StickyHeaderLayout>("scrollAnimOffset") {
52                 @Override
53                 public void setValue(StickyHeaderLayout view, float offset) {
54                     view.mScrollOffset = offset;
55                     view.updateHeaderScroll();
56                 }
57 
58                 @Override
59                 public Float get(StickyHeaderLayout view) {
60                     return view.mScrollOffset;
61                 }
62             };
63 
64     private static final MotionEventProxyMethod INTERCEPT_PROXY = ViewGroup::onInterceptTouchEvent;
65     private static final MotionEventProxyMethod TOUCH_PROXY = ViewGroup::onTouchEvent;
66 
67     private RecyclerView mCurrentRecyclerView;
68     private EmptySpaceView mCurrentEmptySpaceView;
69 
70     private float mLastScroll = 0;
71     private float mScrollOffset = 0;
72     private Animator mOffsetAnimator;
73 
74     private boolean mShouldForwardToRecyclerView = false;
75     private int mHeaderHeight;
76 
StickyHeaderLayout(Context context)77     public StickyHeaderLayout(Context context) {
78         this(context, /* attrs= */ null);
79     }
80 
StickyHeaderLayout(Context context, AttributeSet attrs)81     public StickyHeaderLayout(Context context, AttributeSet attrs) {
82         this(context, attrs, /* defStyleAttr= */ 0);
83     }
84 
StickyHeaderLayout(Context context, AttributeSet attrs, int defStyleAttr)85     public StickyHeaderLayout(Context context, AttributeSet attrs, int defStyleAttr) {
86         this(context, attrs, defStyleAttr, /* defStyleRes= */ 0);
87     }
88 
StickyHeaderLayout(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes)89     public StickyHeaderLayout(Context context, AttributeSet attrs, int defStyleAttr,
90             int defStyleRes) {
91         super(context, attrs, defStyleAttr, defStyleRes);
92     }
93 
94     /**
95      * Sets the recycler view, this sticky header should track
96      */
setCurrentRecyclerView(RecyclerView currentRecyclerView)97     public void setCurrentRecyclerView(RecyclerView currentRecyclerView) {
98         boolean animateReset = mCurrentRecyclerView != null;
99         if (mCurrentRecyclerView != null) {
100             mCurrentRecyclerView.removeOnChildAttachStateChangeListener(this);
101         }
102         mCurrentRecyclerView = currentRecyclerView;
103         mCurrentRecyclerView.addOnChildAttachStateChangeListener(this);
104         findCurrentEmptyView();
105         reset(animateReset);
106     }
107 
getHeaderHeight()108     public int getHeaderHeight() {
109         return mHeaderHeight;
110     }
111 
updateHeaderScroll()112     private void updateHeaderScroll() {
113         mLastScroll = getCurrentScroll();
114         int count = getChildCount();
115         for (int i = 0; i < count; i++) {
116             View child = getChildAt(i);
117             MyLayoutParams lp = (MyLayoutParams) child.getLayoutParams();
118             child.setTranslationY(Math.max(mLastScroll, lp.scrollLimit));
119         }
120     }
121 
getCurrentScroll()122     private float getCurrentScroll() {
123         float scroll;
124         if (mCurrentRecyclerView.getVisibility() != VISIBLE) {
125             // When no list is displayed, assume no scroll.
126             scroll = 0f;
127         } else if (mCurrentEmptySpaceView != null) {
128             // Otherwise use empty space view as reference to position.
129             scroll = mCurrentEmptySpaceView.getY();
130         } else {
131             // If there is no empty space view, but the list is visible, we are scrolled away
132             // completely, so assume all non-sticky children should also be scrolled away.
133             scroll = -mHeaderHeight;
134         }
135         return mScrollOffset + scroll;
136     }
137 
138     @Override
onMeasure(int widthMeasureSpec, int heightMeasureSpec)139     protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
140         super.onMeasure(widthMeasureSpec, heightMeasureSpec);
141 
142         mHeaderHeight = getMeasuredHeight();
143         if (mCurrentEmptySpaceView != null) {
144             mCurrentEmptySpaceView.setFixedHeight(mHeaderHeight);
145         }
146     }
147 
148     /** Resets any previous view translation. */
reset(boolean animate)149     public void reset(boolean animate) {
150         if (mOffsetAnimator != null) {
151             mOffsetAnimator.cancel();
152             mOffsetAnimator = null;
153         }
154 
155         mScrollOffset = 0;
156         if (!animate) {
157             updateHeaderScroll();
158         } else {
159             float startValue = mLastScroll - getCurrentScroll();
160             mOffsetAnimator = ObjectAnimator.ofFloat(this, SCROLL_OFFSET, startValue, 0);
161             mOffsetAnimator.addListener(forEndCallback(() -> mOffsetAnimator = null));
162             mOffsetAnimator.start();
163         }
164     }
165 
166     @Override
onInterceptTouchEvent(MotionEvent event)167     public boolean onInterceptTouchEvent(MotionEvent event) {
168         return (mShouldForwardToRecyclerView = proxyMotionEvent(event, INTERCEPT_PROXY))
169                 || super.onInterceptTouchEvent(event);
170     }
171 
172     @Override
onTouchEvent(MotionEvent event)173     public boolean onTouchEvent(MotionEvent event) {
174         return mShouldForwardToRecyclerView && proxyMotionEvent(event, TOUCH_PROXY)
175                 || super.onTouchEvent(event);
176     }
177 
proxyMotionEvent(MotionEvent event, MotionEventProxyMethod method)178     private boolean proxyMotionEvent(MotionEvent event, MotionEventProxyMethod method) {
179         float dx = mCurrentRecyclerView.getLeft() - getLeft();
180         float dy = mCurrentRecyclerView.getTop() - getTop();
181         event.offsetLocation(dx, dy);
182         try {
183             return method.proxyEvent(mCurrentRecyclerView, event);
184         } finally {
185             event.offsetLocation(-dx, -dy);
186         }
187     }
188 
189     @Override
onChildViewAttachedToWindow(@onNull View view)190     public void onChildViewAttachedToWindow(@NonNull View view) {
191         if (view instanceof EmptySpaceView) {
192             findCurrentEmptyView();
193         }
194     }
195 
196     @Override
onChildViewDetachedFromWindow(@onNull View view)197     public void onChildViewDetachedFromWindow(@NonNull View view) {
198         if (view == mCurrentEmptySpaceView) {
199             findCurrentEmptyView();
200         }
201     }
202 
findCurrentEmptyView()203     private void findCurrentEmptyView() {
204         if (mCurrentEmptySpaceView != null) {
205             mCurrentEmptySpaceView.setOnYChangeCallback(null);
206             mCurrentEmptySpaceView = null;
207         }
208         int childCount = mCurrentRecyclerView.getChildCount();
209         for (int i = 0; i < childCount; i++) {
210             View view = mCurrentRecyclerView.getChildAt(i);
211             if (view instanceof EmptySpaceView) {
212                 mCurrentEmptySpaceView = (EmptySpaceView) view;
213                 mCurrentEmptySpaceView.setFixedHeight(getHeaderHeight());
214                 mCurrentEmptySpaceView.setOnYChangeCallback(this::updateHeaderScroll);
215                 return;
216             }
217         }
218     }
219 
220     @Override
onLayout(boolean changed, int l, int t, int r, int b)221     protected void onLayout(boolean changed, int l, int t, int r, int b) {
222         super.onLayout(changed, l, t, r, b);
223 
224         // Update various stick parameters
225         int count = getChildCount();
226         int stickyHeaderHeight = 0;
227         for (int i = 0; i < count; i++) {
228             View v = getChildAt(i);
229             MyLayoutParams lp = (MyLayoutParams) v.getLayoutParams();
230             if (lp.sticky) {
231                 lp.scrollLimit = -v.getTop() + stickyHeaderHeight;
232                 stickyHeaderHeight += v.getHeight();
233             } else {
234                 lp.scrollLimit = Integer.MIN_VALUE;
235             }
236         }
237         updateHeaderScroll();
238     }
239 
240     @Override
generateDefaultLayoutParams()241     protected LayoutParams generateDefaultLayoutParams() {
242         return new MyLayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT);
243     }
244 
245     @Override
generateLayoutParams(ViewGroup.LayoutParams lp)246     protected LayoutParams generateLayoutParams(ViewGroup.LayoutParams lp) {
247         return new MyLayoutParams(lp.width, lp.height);
248     }
249 
250     @Override
generateLayoutParams(AttributeSet attrs)251     public LayoutParams generateLayoutParams(AttributeSet attrs) {
252         return new MyLayoutParams(getContext(), attrs);
253     }
254 
255     @Override
checkLayoutParams(ViewGroup.LayoutParams p)256     protected boolean checkLayoutParams(ViewGroup.LayoutParams p) {
257         return p instanceof MyLayoutParams;
258     }
259 
260     /**
261      * Return a list of all the children that have the sticky layout param set.
262      */
getStickyChildren()263     public List<View> getStickyChildren() {
264         List<View> stickyChildren = new ArrayList<>();
265         int count = getChildCount();
266         for (int i = 0; i < count; i++) {
267             View v = getChildAt(i);
268             MyLayoutParams lp = (MyLayoutParams) v.getLayoutParams();
269             if (lp.sticky) {
270                 stickyChildren.add(v);
271             }
272         }
273         return stickyChildren;
274     }
275 
276     private static class MyLayoutParams extends LayoutParams {
277 
278         public final boolean sticky;
279         public int scrollLimit;
280 
MyLayoutParams(int width, int height)281         MyLayoutParams(int width, int height) {
282             super(width, height);
283             sticky = false;
284         }
285 
MyLayoutParams(Context c, AttributeSet attrs)286         MyLayoutParams(Context c, AttributeSet attrs) {
287             super(c, attrs);
288             TypedArray a = c.obtainStyledAttributes(attrs, R.styleable.StickyScroller_Layout);
289             sticky = a.getBoolean(R.styleable.StickyScroller_Layout_layout_sticky, false);
290             a.recycle();
291         }
292     }
293 
294     private interface MotionEventProxyMethod {
295 
proxyEvent(ViewGroup view, MotionEvent event)296         boolean proxyEvent(ViewGroup view, MotionEvent event);
297     }
298 
299     /**
300      * Empty view which allows listening for 'Y' changes
301      */
302     public static class EmptySpaceView extends View {
303 
304         private Runnable mOnYChangeCallback;
305         private int mHeight = 0;
306 
EmptySpaceView(Context context)307         public EmptySpaceView(Context context) {
308             super(context);
309             animate().setUpdateListener(v -> notifyYChanged());
310         }
311 
312         /**
313          * Sets the height for the empty view
314          * @return true if the height changed, false otherwise
315          */
setFixedHeight(int height)316         public boolean setFixedHeight(int height) {
317             if (mHeight != height) {
318                 mHeight = height;
319                 requestLayout();
320                 return true;
321             }
322             return false;
323         }
324 
325         @Override
onMeasure(int widthMeasureSpec, int heightMeasureSpec)326         protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
327             super.onMeasure(widthMeasureSpec, makeMeasureSpec(mHeight, EXACTLY));
328         }
329 
setOnYChangeCallback(Runnable callback)330         public void setOnYChangeCallback(Runnable callback) {
331             mOnYChangeCallback = callback;
332         }
333 
334         @Override
onLayout(boolean changed, int left, int top, int right, int bottom)335         protected void onLayout(boolean changed, int left, int top, int right, int bottom) {
336             super.onLayout(changed, left, top, right, bottom);
337             notifyYChanged();
338         }
339 
340         @Override
offsetTopAndBottom(int offset)341         public void offsetTopAndBottom(int offset) {
342             super.offsetTopAndBottom(offset);
343             notifyYChanged();
344         }
345 
346         @Override
setTranslationY(float translationY)347         public void setTranslationY(float translationY) {
348             super.setTranslationY(translationY);
349             notifyYChanged();
350         }
351 
notifyYChanged()352         private void notifyYChanged() {
353             if (mOnYChangeCallback != null) {
354                 mOnYChangeCallback.run();
355             }
356         }
357     }
358 }
359