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