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