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         return mScrollOffset + (mCurrentEmptySpaceView == null ? 0 : mCurrentEmptySpaceView.getY());
124     }
125 
126     @Override
onMeasure(int widthMeasureSpec, int heightMeasureSpec)127     protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
128         super.onMeasure(widthMeasureSpec, heightMeasureSpec);
129 
130         mHeaderHeight = getMeasuredHeight();
131         if (mCurrentEmptySpaceView != null) {
132             mCurrentEmptySpaceView.setFixedHeight(mHeaderHeight);
133         }
134     }
135 
136     /** Resets any previous view translation. */
reset(boolean animate)137     public void reset(boolean animate) {
138         if (mOffsetAnimator != null) {
139             mOffsetAnimator.cancel();
140             mOffsetAnimator = null;
141         }
142 
143         mScrollOffset = 0;
144         if (!animate) {
145             updateHeaderScroll();
146         } else {
147             float startValue = mLastScroll - getCurrentScroll();
148             mOffsetAnimator = ObjectAnimator.ofFloat(this, SCROLL_OFFSET, startValue, 0);
149             mOffsetAnimator.addListener(forEndCallback(() -> mOffsetAnimator = null));
150             mOffsetAnimator.start();
151         }
152     }
153 
154     @Override
onInterceptTouchEvent(MotionEvent event)155     public boolean onInterceptTouchEvent(MotionEvent event) {
156         return (mShouldForwardToRecyclerView = proxyMotionEvent(event, INTERCEPT_PROXY))
157                 || super.onInterceptTouchEvent(event);
158     }
159 
160     @Override
onTouchEvent(MotionEvent event)161     public boolean onTouchEvent(MotionEvent event) {
162         return mShouldForwardToRecyclerView && proxyMotionEvent(event, TOUCH_PROXY)
163                 || super.onTouchEvent(event);
164     }
165 
proxyMotionEvent(MotionEvent event, MotionEventProxyMethod method)166     private boolean proxyMotionEvent(MotionEvent event, MotionEventProxyMethod method) {
167         float dx = mCurrentRecyclerView.getLeft() - getLeft();
168         float dy = mCurrentRecyclerView.getTop() - getTop();
169         event.offsetLocation(dx, dy);
170         try {
171             return method.proxyEvent(mCurrentRecyclerView, event);
172         } finally {
173             event.offsetLocation(-dx, -dy);
174         }
175     }
176 
177     @Override
onChildViewAttachedToWindow(@onNull View view)178     public void onChildViewAttachedToWindow(@NonNull View view) {
179         if (view instanceof EmptySpaceView) {
180             findCurrentEmptyView();
181         }
182     }
183 
184     @Override
onChildViewDetachedFromWindow(@onNull View view)185     public void onChildViewDetachedFromWindow(@NonNull View view) {
186         if (view == mCurrentEmptySpaceView) {
187             findCurrentEmptyView();
188         }
189     }
190 
findCurrentEmptyView()191     private void findCurrentEmptyView() {
192         if (mCurrentEmptySpaceView != null) {
193             mCurrentEmptySpaceView.setOnYChangeCallback(null);
194             mCurrentEmptySpaceView = null;
195         }
196         int childCount = mCurrentRecyclerView.getChildCount();
197         for (int i = 0; i < childCount; i++) {
198             View view = mCurrentRecyclerView.getChildAt(i);
199             if (view instanceof EmptySpaceView) {
200                 mCurrentEmptySpaceView = (EmptySpaceView) view;
201                 mCurrentEmptySpaceView.setFixedHeight(getHeaderHeight());
202                 mCurrentEmptySpaceView.setOnYChangeCallback(this::updateHeaderScroll);
203                 return;
204             }
205         }
206     }
207 
208     @Override
onLayout(boolean changed, int l, int t, int r, int b)209     protected void onLayout(boolean changed, int l, int t, int r, int b) {
210         super.onLayout(changed, l, t, r, b);
211 
212         // Update various stick parameters
213         int count = getChildCount();
214         int stickyHeaderHeight = 0;
215         for (int i = 0; i < count; i++) {
216             View v = getChildAt(i);
217             MyLayoutParams lp = (MyLayoutParams) v.getLayoutParams();
218             if (lp.sticky) {
219                 lp.scrollLimit = -v.getTop() + stickyHeaderHeight;
220                 stickyHeaderHeight += v.getHeight();
221             } else {
222                 lp.scrollLimit = Integer.MIN_VALUE;
223             }
224         }
225         updateHeaderScroll();
226     }
227 
228     @Override
generateDefaultLayoutParams()229     protected LayoutParams generateDefaultLayoutParams() {
230         return new MyLayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT);
231     }
232 
233     @Override
generateLayoutParams(ViewGroup.LayoutParams lp)234     protected LayoutParams generateLayoutParams(ViewGroup.LayoutParams lp) {
235         return new MyLayoutParams(lp.width, lp.height);
236     }
237 
238     @Override
generateLayoutParams(AttributeSet attrs)239     public LayoutParams generateLayoutParams(AttributeSet attrs) {
240         return new MyLayoutParams(getContext(), attrs);
241     }
242 
243     @Override
checkLayoutParams(ViewGroup.LayoutParams p)244     protected boolean checkLayoutParams(ViewGroup.LayoutParams p) {
245         return p instanceof MyLayoutParams;
246     }
247 
248     /**
249      * Return a list of all the children that have the sticky layout param set.
250      */
getStickyChildren()251     public List<View> getStickyChildren() {
252         List<View> stickyChildren = new ArrayList<>();
253         int count = getChildCount();
254         for (int i = 0; i < count; i++) {
255             View v = getChildAt(i);
256             MyLayoutParams lp = (MyLayoutParams) v.getLayoutParams();
257             if (lp.sticky) {
258                 stickyChildren.add(v);
259             }
260         }
261         return stickyChildren;
262     }
263 
264     private static class MyLayoutParams extends LayoutParams {
265 
266         public final boolean sticky;
267         public int scrollLimit;
268 
MyLayoutParams(int width, int height)269         MyLayoutParams(int width, int height) {
270             super(width, height);
271             sticky = false;
272         }
273 
MyLayoutParams(Context c, AttributeSet attrs)274         MyLayoutParams(Context c, AttributeSet attrs) {
275             super(c, attrs);
276             TypedArray a = c.obtainStyledAttributes(attrs, R.styleable.StickyScroller_Layout);
277             sticky = a.getBoolean(R.styleable.StickyScroller_Layout_layout_sticky, false);
278             a.recycle();
279         }
280     }
281 
282     private interface MotionEventProxyMethod {
283 
proxyEvent(ViewGroup view, MotionEvent event)284         boolean proxyEvent(ViewGroup view, MotionEvent event);
285     }
286 
287     /**
288      * Empty view which allows listening for 'Y' changes
289      */
290     public static class EmptySpaceView extends View {
291 
292         private Runnable mOnYChangeCallback;
293         private int mHeight = 0;
294 
EmptySpaceView(Context context)295         public EmptySpaceView(Context context) {
296             super(context);
297             animate().setUpdateListener(v -> notifyYChanged());
298         }
299 
300         /**
301          * Sets the height for the empty view
302          * @return true if the height changed, false otherwise
303          */
setFixedHeight(int height)304         public boolean setFixedHeight(int height) {
305             if (mHeight != height) {
306                 mHeight = height;
307                 requestLayout();
308                 return true;
309             }
310             return false;
311         }
312 
313         @Override
onMeasure(int widthMeasureSpec, int heightMeasureSpec)314         protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
315             super.onMeasure(widthMeasureSpec, makeMeasureSpec(mHeight, EXACTLY));
316         }
317 
setOnYChangeCallback(Runnable callback)318         public void setOnYChangeCallback(Runnable callback) {
319             mOnYChangeCallback = callback;
320         }
321 
322         @Override
onLayout(boolean changed, int left, int top, int right, int bottom)323         protected void onLayout(boolean changed, int left, int top, int right, int bottom) {
324             super.onLayout(changed, left, top, right, bottom);
325             notifyYChanged();
326         }
327 
328         @Override
offsetTopAndBottom(int offset)329         public void offsetTopAndBottom(int offset) {
330             super.offsetTopAndBottom(offset);
331             notifyYChanged();
332         }
333 
334         @Override
setTranslationY(float translationY)335         public void setTranslationY(float translationY) {
336             super.setTranslationY(translationY);
337             notifyYChanged();
338         }
339 
notifyYChanged()340         private void notifyYChanged() {
341             if (mOnYChangeCallback != null) {
342                 mOnYChangeCallback.run();
343             }
344         }
345     }
346 }
347