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