blob: 206c721016c2385aaf8972a455cd3e8320897961 [file] [log] [blame]
Brian Attwellb7e43642014-06-02 14:33:04 -07001package com.android.contacts.widget;
2
3import com.android.contacts.R;
4import com.android.contacts.test.NeededForReflection;
5
6import android.animation.ObjectAnimator;
7import android.content.Context;
8import android.graphics.Canvas;
9import android.util.AttributeSet;
10import android.view.MotionEvent;
11import android.view.VelocityTracker;
12import android.view.View;
13import android.view.ViewGroup;
14import android.view.ViewConfiguration;
15import android.view.animation.AccelerateInterpolator;
16import android.view.animation.Interpolator;
17import android.widget.EdgeEffect;
18import android.widget.LinearLayout;
19import android.widget.Scroller;
20import android.widget.ScrollView;
21
22/**
23 * A custom {@link ViewGroup} that operates similarly to a {@link ScrollView}, except with multiple
24 * subviews. These subviews are scrolled or shrinked one at a time, until each reaches their
25 * minimum or maximum value.
26 *
27 * MultiShrinkScroller is designed for a specific problem. As such, this class is designed to be
28 * used with a specific layout file: quickcontact_activity.xml. MultiShrinkScroller expects subviews
29 * with specific ID values.
30 *
31 * MultiShrinkScroller's code is heavily influenced by ScrollView. Nonetheless, several ScrollView
32 * features are missing. For example: handling of KEYCODES, OverScroll bounce and saving
33 * scroll state in savedInstanceState bundles.
34 */
35public class MultiShrinkScroller extends LinearLayout {
36
37 /**
38 * 1000 pixels per millisecond. Ie, 1 pixel per second.
39 */
40 private static final int PIXELS_PER_SECOND = 1000;
41
42 private float[] mLastEventPosition = { 0, 0 };
43 private VelocityTracker mVelocityTracker;
44 private boolean mIsBeingDragged = false;
45 private boolean mReceivedDown = false;
46
47 private ScrollView mScrollView;
48 private View mScrollViewChild;
49 private View mToolbar;
50 private MultiShrinkScrollerListener mListener;
51
52 private final Scroller mScroller;
53 private final EdgeEffect mEdgeGlowBottom;
54 private final int mTouchSlop;
55 private final int mMaximumVelocity;
56 private final int mMinimumVelocity;
57 private final int mMaximumHeaderHeight;
58 private final int mMinimumHeaderHeight;
59 private final int mTransparentStartHeight;
60 private final int mElasticScrollOverTopRegion;
61
62 public interface MultiShrinkScrollerListener {
63 void onScrolledOffBottom();
64 }
65
66 // Interpolator from android.support.v4.view.ViewPager
67 private static final Interpolator sInterpolator = new Interpolator() {
68
69 /**
70 * {@inheritDoc}
71 */
72 @Override
73 public float getInterpolation(float t) {
74 t -= 1.0f;
75 return t * t * t * t * t + 1.0f;
76 }
77 };
78
79 public MultiShrinkScroller(Context context) {
80 this(context, null);
81 }
82
83 public MultiShrinkScroller(Context context, AttributeSet attrs) {
84 this(context, attrs, 0);
85 }
86
87 public MultiShrinkScroller(Context context, AttributeSet attrs, int defStyleAttr) {
88 super(context, attrs, defStyleAttr);
89
90 final ViewConfiguration configuration = ViewConfiguration.get(context);
91 setFocusable(false);
92 // Drawing must be enabled in order to support EdgeEffect
93 setWillNotDraw(/* willNotDraw = */ false);
94
95 mEdgeGlowBottom = new EdgeEffect(context);
96 mScroller = new Scroller(context, sInterpolator);
97 mTouchSlop = configuration.getScaledTouchSlop();
98 mMinimumVelocity = configuration.getScaledMinimumFlingVelocity();
99 mMaximumVelocity = configuration.getScaledMaximumFlingVelocity();
100 mMaximumHeaderHeight = (int) getResources().getDimension(
101 R.dimen.quickcontact_maximum_header_height);
102 mMinimumHeaderHeight = (int) getResources().getDimension(
103 R.dimen.quickcontact_minimum_header_height);
104 mTransparentStartHeight = (int) getResources().getDimension(
105 R.dimen.quickcontact_starting_empty_height);
106 mElasticScrollOverTopRegion = (int) getResources().getDimension(
107 R.dimen.quickcontact_elastic_scroll_over_top_region);
108 }
109
110 /**
111 * This method must be called inside the Activity's OnCreate.
112 */
113 public void initialize(MultiShrinkScrollerListener listener) {
114 mScrollView = (ScrollView) findViewById(R.id.content_scroller);
115 mScrollViewChild = findViewById(R.id.card_container);
116 mToolbar = findViewById(R.id.toolbar_parent);
117 mListener = listener;
118 }
119
120 @Override
121 public boolean onInterceptTouchEvent(MotionEvent event) {
122 // The only time we want to intercept touch events is when we are being dragged.
123 return shouldStartDrag(event);
124 }
125
126 private boolean shouldStartDrag(MotionEvent event) {
127 if (mIsBeingDragged) {
128 mIsBeingDragged = false;
129 return false;
130 }
131
132 switch (event.getAction()) {
133 // If we are in the middle of a fling and there is a down event, we'll steal it and
134 // start a drag.
135 case MotionEvent.ACTION_DOWN:
136 updateLastEventPosition(event);
137 if (!mScroller.isFinished()) {
138 startDrag();
139 return true;
140 } else {
141 mReceivedDown = true;
142 }
143 break;
144
145 // Otherwise, we will start a drag if there is enough motion in the direction we are
146 // capable of scrolling.
147 case MotionEvent.ACTION_MOVE:
148 if (motionShouldStartDrag(event)) {
149 updateLastEventPosition(event);
150 startDrag();
151 return true;
152 }
153 break;
154 }
155
156 return false;
157 }
158
159 @Override
160 public boolean onTouchEvent(MotionEvent event) {
161 final int action = event.getAction();
162
163 if (mVelocityTracker == null) {
164 mVelocityTracker = VelocityTracker.obtain();
165 }
166 mVelocityTracker.addMovement(event);
167
168 if (!mIsBeingDragged) {
169 if (shouldStartDrag(event)) {
170 return true;
171 }
172
173 if (action == MotionEvent.ACTION_UP && mReceivedDown) {
174 mReceivedDown = false;
175 return performClick();
176 }
177 return true;
178 }
179
180 switch (action) {
181 case MotionEvent.ACTION_MOVE:
182 final float delta = updatePositionAndComputeDelta(event);
183 scrollTo(0, getScroll() + (int) delta);
184 mReceivedDown = false;
185
186 if (mIsBeingDragged) {
187 final int heightScrollViewChild = mScrollViewChild.getHeight();
188 final int pulledToY = mScrollView.getScrollY() + (int) delta;
189 if (pulledToY > heightScrollViewChild - mScrollView.getHeight()
190 && mToolbar.getHeight() == mMinimumHeaderHeight) {
191 // The ScrollView is being pulled upwards while there is no more
192 // content offscreen, and the view port is already fully expanded.
193 mEdgeGlowBottom.onPull(delta / getHeight(), 1 - event.getX() / getWidth());
194 }
195 if (!mEdgeGlowBottom.isFinished()) {
196 postInvalidateOnAnimation();
197 }
198
199 }
200 break;
201
202 case MotionEvent.ACTION_UP:
203 case MotionEvent.ACTION_CANCEL:
204 stopDrag(action == MotionEvent.ACTION_CANCEL);
205 mReceivedDown = false;
206 break;
207 }
208
209 return true;
210 }
211
212 private void startDrag() {
213 mIsBeingDragged = true;
214 mScroller.abortAnimation();
215 }
216
217 private void stopDrag(boolean cancelled) {
218 mIsBeingDragged = false;
219 if (!cancelled && getChildCount() > 0) {
220 final float velocity = getCurrentVelocity();
221 if (velocity > mMinimumVelocity || velocity < -mMinimumVelocity) {
222 fling(-velocity);
223 onDragFinished(mScroller.getFinalY() - mScroller.getStartY());
224 } else {
225 onDragFinished(/* flingDelta = */ 0);
226 }
227 } else {
228 onDragFinished(/* flingDelta = */ 0);
229 }
230
231 if (mVelocityTracker != null) {
232 mVelocityTracker.recycle();
233 mVelocityTracker = null;
234 }
235
236 mEdgeGlowBottom.onRelease();
237 }
238
239 private void onDragFinished(int flingDelta) {
240 if (!snapToTop(flingDelta)) {
241 // The drag/fling won't result in the content at the top of the Window. Consider
242 // snapping the content to the bottom of the window.
243 snapToBottom(flingDelta);
244 }
245 }
246
247 /**
248 * If needed, snap the subviews to the top of the Window.
249 */
250 private boolean snapToTop(int flingDelta) {
251 if (-getScroll() - flingDelta < 0
252 && -getScroll() - flingDelta > -mTransparentStartHeight
253 - mElasticScrollOverTopRegion) {
254 // We finish scrolling above the empty starting height, and aren't projected
255 // to fling past the top of the Window by mElasticScrollOverTopRegion worth of
256 // pixels, so elastically snap the empty space shut.
257 mScroller.forceFinished(true);
258 smoothScrollBy(-getScroll() + mTransparentStartHeight);
259 return true;
260 }
261 return false;
262 }
263
264 /**
265 * If needed, scroll all the subviews off the bottom of the Window.
266 */
267 private void snapToBottom(int flingDelta) {
268 if (-getScroll() - flingDelta > 0) {
269 mScroller.forceFinished(true);
270 ObjectAnimator translateAnimation = ObjectAnimator.ofInt(this, "scroll",
271 getScroll() - getScrollUntilOffBottom());
272 translateAnimation.setRepeatCount(0);
273 translateAnimation.setInterpolator(new AccelerateInterpolator());
274 translateAnimation.start();
275 }
276 }
277
278 @Override
279 public void scrollTo(int x, int y) {
280 int delta = y - getScroll();
281 if (delta > 0) {
282 scrollUp(delta);
283 } else {
284 scrollDown(delta);
285 }
286 }
287
288 @NeededForReflection
289 public void setScroll(int scroll) {
290 scrollTo(0, scroll);
291 }
292
293 /**
294 * Returns the total amount scrolled inside the nested ScrollView + the amount of shrinking
295 * performed on the ToolBar.
296 */
297 public int getScroll() {
298 final LinearLayout.LayoutParams toolbarLayoutParams
299 = (LayoutParams) mToolbar.getLayoutParams();
300 return mTransparentStartHeight - toolbarLayoutParams.topMargin
301 + mMaximumHeaderHeight - toolbarLayoutParams.height + mScrollView.getScrollY();
302 }
303
304 /**
305 * Return amount of scrolling needed in order for all the visible subviews to scroll off the
306 * bottom.
307 */
308 public int getScrollUntilOffBottom() {
309 return getHeight() + getScroll() - mTransparentStartHeight;
310 }
311
312 @Override
313 public void computeScroll() {
314 if (mScroller.computeScrollOffset()) {
315 // Examine the fling results in order to activate EdgeEffect when we fling to the end.
316 final int oldScroll = getScroll();
317 scrollTo(0, mScroller.getCurrY());
318 final int delta = mScroller.getCurrY() - oldScroll;
319 final int distanceFromMaxScrolling = getMaximumScrollUpwards() - getScroll();
320 if (delta > distanceFromMaxScrolling && distanceFromMaxScrolling > 0) {
321 mEdgeGlowBottom.onAbsorb((int) mScroller.getCurrVelocity());
322 }
323
324 if (!awakenScrollBars()) {
325 // Keep on drawing until the animation has finished.
326 postInvalidateOnAnimation();
327 }
328 if (mScroller.getCurrY() >= getMaximumScrollUpwards()) {
329 mScroller.abortAnimation();
330 }
331 }
332 }
333
334 @Override
335 public void draw(Canvas canvas) {
336 super.draw(canvas);
337
338 if (!mEdgeGlowBottom.isFinished()) {
339 final int restoreCount = canvas.save();
340 final int width = getWidth() - getPaddingLeft() - getPaddingRight();
341 final int height = getHeight();
342
343 // Draw the EdgeEffect on the bottom of the Window (Or a little bit below the bottom
344 // of the Window if we start to scroll upwards while EdgeEffect is visible). This
345 // does not need to consider the case where this MultiShrinkScroller doesn't fill
346 // the Window, since the nested ScrollView should be set to fillViewport.
347 canvas.translate(-width + getPaddingLeft(),
348 height + getMaximumScrollUpwards() - getScroll());
349
350 canvas.rotate(180, width, 0);
351 mEdgeGlowBottom.setSize(width, height);
352 if (mEdgeGlowBottom.draw(canvas)) {
353 postInvalidateOnAnimation();
354 }
355 canvas.restoreToCount(restoreCount);
356 }
357 }
358
359 private float getCurrentVelocity() {
360 mVelocityTracker.computeCurrentVelocity(PIXELS_PER_SECOND, mMaximumVelocity);
361 return mVelocityTracker.getYVelocity();
362 }
363
364 private void fling(float velocity) {
365 // For reasons I do not understand, scrolling is less janky when maxY=Integer.MAX_VALUE
366 // then when maxY is set to an actual value.
367 mScroller.fling(0, getScroll(), 0, (int) velocity, 0, 0, -Integer.MAX_VALUE,
368 Integer.MAX_VALUE);
369 invalidate();
370 }
371
372 private int getMaximumScrollUpwards() {
373 return mTransparentStartHeight
374 // How much the Header view can compress
375 + mMaximumHeaderHeight - mMinimumHeaderHeight
376 // How much the ScrollView can scroll. 0, if child is smaller than ScrollView.
377 + Math.max(0, mScrollViewChild.getHeight() - getHeight() + mMinimumHeaderHeight);
378 }
379
380 private void scrollUp(int delta) {
381 LinearLayout.LayoutParams toolbarLayoutParams = (LayoutParams) mToolbar.getLayoutParams();
382 if (toolbarLayoutParams.topMargin != 0) {
383 final int originalValue = toolbarLayoutParams.topMargin;
384 toolbarLayoutParams.topMargin -= delta;
385 toolbarLayoutParams.topMargin = Math.max(toolbarLayoutParams.topMargin, 0);
386 mToolbar.setLayoutParams(toolbarLayoutParams);
387 delta -= originalValue - toolbarLayoutParams.topMargin;
388 }
389 if (toolbarLayoutParams.height != mMinimumHeaderHeight) {
390 final int originalValue = toolbarLayoutParams.height;
391 toolbarLayoutParams.height -= delta;
392 toolbarLayoutParams.height = Math.max(toolbarLayoutParams.height, mMinimumHeaderHeight);
393 mToolbar.setLayoutParams(toolbarLayoutParams);
394 delta -= originalValue - toolbarLayoutParams.height;
395 }
396 mScrollView.scrollBy(0, delta);
397 }
398
399 private void scrollDown(int delta) {
400 LinearLayout.LayoutParams toolbarLayoutParams = (LayoutParams) mToolbar.getLayoutParams();
401 if (mScrollView.getScrollY() > 0) {
402 final int originalValue = mScrollView.getScrollY();
403 mScrollView.scrollBy(0, delta);
404 delta -= mScrollView.getScrollY() - originalValue;
405 }
406 if (toolbarLayoutParams.height != mMaximumHeaderHeight) {
407 final int originalValue = toolbarLayoutParams.height;
408 toolbarLayoutParams.height -= delta;
409 toolbarLayoutParams.height = Math.min(toolbarLayoutParams.height, mMaximumHeaderHeight);
410 mToolbar.setLayoutParams(toolbarLayoutParams);
411 delta -= originalValue - toolbarLayoutParams.height;
412 }
413 toolbarLayoutParams.topMargin -= delta;
414 mToolbar.setLayoutParams(toolbarLayoutParams);
415
416 if (mListener != null && getScrollUntilOffBottom() <= 0) {
417 post(new Runnable() {
418 @Override
419 public void run() {
420 mListener.onScrolledOffBottom();
421 }
422 });
423 }
424 }
425
426 private void updateLastEventPosition(MotionEvent event) {
427 mLastEventPosition[0] = event.getX();
428 mLastEventPosition[1] = event.getY();
429 }
430
431 private boolean motionShouldStartDrag(MotionEvent event) {
432 final float deltaX = event.getX() - mLastEventPosition[0];
433 final float deltaY = event.getY() - mLastEventPosition[1];
434 final boolean draggedX = (deltaX > mTouchSlop || deltaX < -mTouchSlop);
435 final boolean draggedY = (deltaY > mTouchSlop || deltaY < -mTouchSlop);
436 return draggedY && !draggedX;
437 }
438
439 private float updatePositionAndComputeDelta(MotionEvent event) {
440 final int VERTICAL = 1;
441 final float position = mLastEventPosition[VERTICAL];
442 updateLastEventPosition(event);
443 return position - mLastEventPosition[VERTICAL];
444 }
445
446 private void smoothScrollBy(int delta) {
447 mScroller.startScroll(0, getScroll(), 0, delta);
448 invalidate();
449 }
450}