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