If you want to implement a refresh control yourself, you only need to master this knowledge

If you want to implement a refresh control yourself, you only need to master this knowledge

There are many refresh controls in the Android camp now, and they are of varying quality. The author tried to work on its personalization and scrolling from a different angle. The author hopes that this refresh control can support most list controls like Google's SwipeRefreshLayout, and have more functions. It is best to support personalization very conveniently. Whether the ability to cross the border during scrolling will also bring a better interactive experience than ordinary refresh controls. The open source library is here, TwinklingRefreshLayout. If you like it, please star it. The author's article also revolves around the implementation of this control.

For convenience, the author inherits TwinklingRefreshLayout directly from FrameLayout instead of ViewGroup, which can save some troubles such as onMeasure and onLayout. The Header and Footer are done by setting the Gravity property of View through LayoutParams.

1. View's onAttachedToWindow() method

First of all, View has no obvious life cycle, and we can't add a header and footer to the control in the addView() constructor, so the appropriate time for this operation is before onDraw() - in the onAttachedToWindow() method.

At this point, the View is added to the form, and the View has a Surface for display, and will start drawing. Therefore, it is guaranteed to be called before onDraw(), but it may be called at any time before onDraw(Canvas), including before or after onMeasure(int, int). It is more suitable for performing some initialization operations. (In addition, this method will also be called back when the Home button is blocked)

  • onDetachedFromWindow() corresponds to the onAttachedToWindow() method.
  • ViewGroup first calls its own onAttachedToWindow() method, and then calls the onAttachedToWindow() method of each of its children, so that this method is spread throughout the view tree, and visibility has no effect on this method.
  • The onAttachedToWindow method is called when the Activity resumes, that is, when the window corresponding to the act is added, and each view will only be called once, with the parent view being called first. It will be called regardless of the view's visibility state, and is suitable for performing some view-specific initialization operations;
  • The onDetachedFromWindow method is called when the Activity is destroyed, that is, when the window corresponding to the act is deleted, and each view will only be called once, the parent view is called later, and it will be called regardless of the visibility state of the view, which is suitable for the final cleanup operation;

As for TwinklingRefreshLayout, Header and Footer need to be displayed in time, and View has no obvious life cycle, so setting it in onAttachedToWindow() can ensure that the refresh control is added before onDraw().

  1. @Override
  2. protected void onAttachedToWindow() {
  3. super.onAttachedToWindow();
  4.  
  5. //Add header
  6. FrameLayout headViewLayout = new FrameLayout(getContext());
  7. LayoutParams layoutParams = new LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, 0);
  8. layoutParams.gravity = Gravity.TOP ;
  9. headViewLayout.setLayoutParams(layoutParams);
  10.  
  11. mHeadLayout = headViewLayout;
  12. this.addView(mHeadLayout); //addView( view ,-1) adds to the position of -1
  13.  
  14. //Add the bottom
  15. FrameLayout bottomViewLayout = new FrameLayout(getContext());
  16. LayoutParams layoutParams2 = new LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, 0);
  17. layoutParams2.gravity = Gravity.BOTTOM;
  18. bottomViewLayout.setLayoutParams(layoutParams2);
  19.  
  20. mBottomLayout = bottomViewLayout;
  21. this.addView(mBottomLayout);
  22. //...other steps
  23. }

However, when TwinklingRefreshLayout is applied in an Activity or Fragment, the onAttachedToWindow() method may be re-triggered by executing onResume, which may cause the Header and Footer to be repeatedly created and block the originally added View. Therefore, it is necessary to add a judgment:

  1. @Override
  2. protected void onAttachedToWindow() {
  3. super.onAttachedToWindow();
  4. System.out.println ( "onAttachedToWindow bound window" );
  5.  
  6. //Add header
  7. if (mHeadLayout == null ) {
  8. FrameLayout headViewLayout = new FrameLayout(getContext());
  9. LayoutParams layoutParams = new LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, 0);
  10. layoutParams.gravity = Gravity.TOP ;
  11. headViewLayout.setLayoutParams(layoutParams);
  12.  
  13. mHeadLayout = headViewLayout;
  14.  
  15. this.addView(mHeadLayout); //addView( view ,-1) adds to the position of -1
  16.  
  17. if (mHeadView == null ) setHeaderView(new RoundDotView(getContext()));
  18. }
  19. //...
  20. }

2. View event distribution mechanism

The event distribution process is completed by dispatchTouchEvent, onInterceptTouchEvent and onTouchEvent. Since the event is transmitted from top to bottom, for ViewGroup, I think the most important method is onInterceptTouchEvent, which determines whether the event can continue to be transmitted downward. See the following pseudo code:

  1. public boolean dispatchTouchEvent(MotionEvent ev){
  2. boolean consume = false ;
  3. if (onInterceptTouchEvent(ev)) {
  4. consume = onTouchEvent(ev);
  5. } else {
  6. consume = child.dispatchTouchEvent(ev);
  7. }
  8. return consume;
  9. }

As shown in the code, if ViewGroup intercepts the event (onInterceptTouchEvent returns true), the event will be consumed in the onTouchEvent method of ViewGroup and will not be passed to the child View; otherwise, the event will be handed over to the child View for distribution.

What we need to do is to intercept the event in time when the child View scrolls to the top or bottom, and let ViewGroup's onTouchEvent handle the sliding event.

3. Determine if the child View scrolls to the boundary

When should the event be intercepted? For Header, when the finger slides down, that is, dy>0 and the sub-View has scrolled to the top (cannot scroll up anymore), it is intercepted; for bottom, when dy<0 and the sub-View has scrolled to the bottom (cannot scroll down anymore), it is intercepted:

  1. @Override
  2. public boolean onInterceptTouchEvent(MotionEvent ev) {
  3. switch (ev.getAction()) {
  4. case MotionEvent.ACTION_DOWN:
  5. mTouchY = ev.getY();
  6. break;
  7. case MotionEvent.ACTION_MOVE:
  8. float dy = ev.getY() - mTouchY;
  9.  
  10. if (dy > 0 && !canChildScrollUp()) {
  11. state = PULL_DOWN_REFRESH;
  12. return   true ;
  13. } else if (dy < 0 && !canChildScrollDown() && enableLoadmore) {
  14. state = PULL_UP_LOAD;
  15. return   true ;
  16. }
  17. break;
  18. }
  19. return super.onInterceptTouchEvent(ev);
  20. }

Determine whether the View can continue to scroll up. For SDK versions above 14, the v4 package provides the following method:

  1. public boolean canChildScrollUp() {
  2. return ViewCompat.canScrollVertically(mChildView, -1);
  3. }

In other cases, it is directly handed over to the child View, and ViewGroup has no control over it.

4. ViewGroup's onTouchEvent method

At this point, the scrolling of the child View has been handed over to the child View itself. The events that ViewGroup needs to handle are only two critical states, that is, the state where the user may want to refresh when pulling down and the state where the user may want to load more when pulling up. This is the state recorded above. The next thing is simple, just listen to ACTION_MOVE and ACTION_UP.

First, in ACTION_DOWN, we need to record the original finger pressing position mTouchY, and then in a series of ACTION_MOVE processes, get the current displacement (ev.getY()-mTouchY), and then use some calculation method to continuously calculate the displacement distance offsetY of the current child View, call mChildView.setTranslationY(offsetY) to continuously set the displacement of the child View, and at the same time apply for the layout height to HeadLayout to complete the display of the top control. The calculation method used by the author is the interpolator.

In ACTION_UP, it is necessary to determine whether the displacement of the child View has reached the requirement of entering the refresh or loading more state, that is, mChildView.getTranslationY() >= mHeadHeight - mTouchSlop, where mTouchSlop exists to prevent jitter. When it is determined that the refresh state has been entered, the displacement of the current child View is between HeadHeight and maxHeadHeight, so the displacement of the child View needs to be returned to HeadHeight, otherwise it will be directly returned to 0.

5. Interpolator

Interpolator is used for time interpolation in animation. Its function is to map the change of floating point value from 0 to 1 to another floating point value change. The calculation method mentioned above is as follows:

  1. float offsetY = decelerateInterpolator.getInterpolation(dy / mWaveHeight / 2) * dy / 2;

Among them, (dy / mWaveHeight / 2) is a floating point value between 0 and 1. As the pull-down height increases, this value becomes larger and larger, and the interpolation obtained through decelerateInterpolator also becomes larger and larger, but the change in these values ​​is getting smaller and smaller (decelerate effect). dy represents the distance the finger moves. This is just a calculation method used by the author for the softness of sliding. The maximum distance of head displacement is mWaveHeight = dy/2. From this perspective, you can find that dy / mWaveHeight / 2 will change from 0 to 1. Interpolator inherits from the TimeInterpolator interface. The source code is as follows:

  1. public interface TimeInterpolator {
  2. /**
  3. * Maps a value representing the elapsed fraction of an animation to a value that represents
  4. * the interpolated fraction. This interpolated value is   then multiplied by the change in  
  5. * value of an animation to derive the animated value at the current elapsed animation time .
  6. *
  7. * @param input A value between 0 and 1.0 indicating our current point
  8. * in the animation where 0 represents the start and 1.0 represents
  9. * the end  
  10. * @ return The interpolation value. This value can be more than 1.0 for  
  11. * interpolators which overshoot their targets, or less than 0 for  
  12. * interpolators that undershoot their targets.
  13. */
  14. float getInterpolation( float input);
  15. }

getInterpolation receives a float parameter between 0.0 and 1.0, where 0.0 represents the start of the animation and 1.0 represents the end of the animation. The return value can be greater than 1.0 or less than 0.0, such as OvershotInterpolator. So getInterpolation() is a function that returns a function value of about 0 to 1 when the input is 0 to 1.

6. Property Animation

As mentioned above, when the finger is lifted, the displacement of mChildView will either return to mHeadHeight or return to 0. Directly calling setTranslationY() is not user-friendly, so we use attribute animation here. Originally, we could use mChildView.animate() method to complete attribute animation directly, but because it needs to be compatible with lower versions and call back some parameters, we use ObjectAnimator here:

  1. private void animChildView( float endValue, long duration) {
  2. ObjectAnimator oa = ObjectAnimator.ofFloat(mChildView, "translationY" , mChildView.getTranslationY(), endValue);
  3. oa.setDuration(duration);
  4. oa.setInterpolator(new DecelerateInterpolator()); //Set the rate to decrement
  5. oa.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
  6. @Override
  7. public void onAnimationUpdate(ValueAnimator animation) {
  8. int height = ( int ) mChildView.getTranslationY(); //Get the current y position of mChildView
  9. height = Math. abs (height);
  10.  
  11. mHeadLayout.getLayoutParams().height = height;
  12. mHeadLayout.requestLayout();
  13. }
  14. });
  15. oa.start();
  16. }

Traditional tween animation can only realize four animation operations: move, scale, rotate, and fade in and out. Moreover, it only changes the display effect of the View and the appearance of the canvas, but does not actually change the properties of the View. For example, if you use tween animation to move a button, the button will only respond if you click it in the original position, while attribute animation can actually move the button. The simplest way to use attribute animation is to use ValueAnimator:

  1. ValueAnimator anim = ValueAnimator.ofFloat(0f, 1f);
  2. anim.start();

It can pass multiple parameters, such as ValueAnimator.ofFloat(0f, 5f, 3f, 10f), and it will calculate in sequence according to the set interpolator. For example, if you want to make a heartbeat effect, it is a good choice to use ValueAnimator to control the current scale value of the heart. In addition, you can also call the setStartDelay() method to set the animation delay time, call the setRepeatCount() and setRepeatMode() methods to set the number of animation loops and the loop mode.

If you want to achieve the displacement of View, ValueAnimator is obviously more troublesome. We can use the subclass of ValueAnimator, ObjectAnimator, as follows:

  1. ObjectAnimator animator = ObjectAnimator.ofFloat(textview, "alpha" , 1f, 0f, 1f);
  2. animator.setDuration(5000);
  3. animator.start();

The first value passed in is an Object, not limited to View. The second parameter passed in is an attribute of the Object. For example, if "abc" is passed in, ObjectAnimator will look for getAbc() and setAbc(...) methods in the Object. If not, the animation will have no effect. It should handle the corresponding exceptions internally. In addition, you can use AnimatorSet to play multiple attribute animations at the same time, or write attribute animations in XML.

7. Personalize the Header and Footer interfaces

To realize personalized Header and Footer, the most important thing is of course to call back the coefficients during the sliding process. When ACTION_MOVE, ACTION_UP, and when mChildView is performing attribute animation, the current state of mChildView is very clear, so just write an interface.

  1. public interface IHeaderView {
  2. View getView();
  3.  
  4. void onPullingDown( float fraction, float maxHeadHeight, float headHeight);
  5.  
  6. void onPullReleasing( float fraction, float maxHeadHeight, float headHeight);
  7.  
  8. void startAnim( float maxHeadHeight, float headHeight);
  9.  
  10. void onFinish();
  11. }

The getView() method ensures that the externally set View can be obtained in TwinklingRefreshLayout. onPullingDown() is the callback method for ACTION_MOVE during the pull-down process, onPullReleasing() is the callback method for ACTION_UP in the pull-down state, and startAnim() is the callback method for refreshing. Among them, fraction = mChildView.getTranslationY()/mHeadHeight. When fraction = 1, the displacement of mChildView is exactly the height of HeadLayout. When fraction>1, it exceeds the height of HeadLayout, and its maximum height can reach mWaveHeight/mHeadHeight. In this way, we only need to write a View to implement this interface to achieve personalization, and all the necessary parameters are available!

8. Implement out-of-bounds rebound

It is a common problem of refresh controls that inherit and ViewGroup that cannot respond to cross-border when the finger quickly scrolls to the top. Without inheriting from a specific list control, it cannot obtain the list control's Scroller, the current scrolling speed of the list control, and it cannot predict when the list control will scroll to the top; at the same time, except for the critical state event, all other events of ViewGroup are handled by the child View. The only operation we can obtain about the child View is the simple finger touch event. So, let's do it!

  1. mChildView.setOnTouchListener(new OnTouchListener() {
  2. @Override
  3. public boolean onTouch( View v, MotionEvent event) {
  4. return gestureDetector.onTouchEvent(event);
  5. }
  6. });

We handed the touch events on mChildView to a tool class GestureDetector to handle, which can assist in detecting the user's single click, slide, long press, double click, fast slide, etc. Here we only need to rewrite the onFling() method and get the velocity velocityY of the finger in the Y direction. If we can find that mChildView has scrolled to the top in time, the problem can be solved.

  1. GestureDetector gestureDetector = new GestureDetector(getContext(), new GestureDetector.SimpleOnGestureListener() {
  2.  
  3. @Override
  4. public boolean onFling(MotionEvent e1, MotionEvent e2, float velocityX, float velocityY) {
  5. mVelocityY = velocityY;
  6. }
  7. });

In addition, you can use VelocityTracker to obtain the speed, which is more troublesome:

  1. VelocityTracker tracker = VelocityTracker.obtain();
  2. tracker.addMovement(ev);
  3. //Then use the following method to get the speed at the appropriate position
  4. tracker.computeCurrentVelocity(1000);
  5. mVelocityY = ( int )tracker.getYVelocity();

Continue to implement out-of-bounds rebound. For RecyclerView and AbsListView, they provide OnScrollListener to obtain the scroll status:

  1. if (mChildView instanceof RecyclerView) {
  2. ((RecyclerView) mChildView).addOnScrollListener(new RecyclerView.OnScrollListener() {
  3. @Override
  4. public void onScrollStateChanged(RecyclerView recyclerView, int newState) {
  5. if (!isRefreshing && !isLoadingmore && newState == RecyclerView.SCROLL_STATE_IDLE) {
  6. if (mVelocityY >= 5000 && ScrollingUtil.isRecyclerViewToTop((RecyclerView) mChildView)) {
  7. animOverScrollTop();
  8. }
  9. if (mVelocityY <= -5000 && ScrollingUtil.isRecyclerViewToBottom((RecyclerView) mChildView)) {
  10. animOverScrollBottom();
  11. }
  12. }
  13. super.onScrollStateChanged(recyclerView, newState);
  14. }
  15. });
  16. }

The author selected a critical value of the scrolling speed. The cross-border rebound is allowed only when the scrolling speed in the Y direction is greater than 5000. The OnScrollListener of RecyclerView allows us to obtain the change of the scrolling state. When scrolling to the top, the scrolling is completed, the state changes to SCROLL_STATE_IDLE, and the cross-border rebound animation is executed. This strategy also has some defects. It cannot obtain the scrolling speed when the mChildView scrolls to the top, and it cannot achieve a more friendly cross-border effect according to different scrolling speeds. The current cross-border height is fixed and needs to be optimized later, such as using acceleration to calculate. Whether it is feasible remains to be verified.

9. Rolling delay calculation strategy

The above method works well for RecyclerView and AbsListView, but it is a headache for ScrollView and WebView. The only way to judge is to use a delay calculation strategy to see if it reaches the top after a period of time. The idea of ​​the delay strategy is to achieve a progressive calculation effect by sending a series of delayed messages. Specifically, you can use the postDelayed method of Handler or View, or the sleep method of thread. Another point to mention is that if you need to calculate a value in a continuous loop, such as a custom View that needs to implement an animation based on a certain value change, it is best not to use Thread + while loop to calculate. Using ValueAnimator would be a better choice. Here the author chose the Handler method.

  1. @Override
  2. public boolean onFling(MotionEvent e1, MotionEvent e2, float velocityX, float velocityY) {
  3. mVelocityY = velocityY;
  4. if (!(mChildView instanceof AbsListView || mChildView instanceof RecyclerView)) {
  5. //Neither AbsListView nor RecyclerView, since these do not implement the OnScrollListener interface, the status cannot be called back, and only the delay strategy can be used
  6. if (Math. abs (mVelocityY) >= 5000) {
  7. mHandler.sendEmptyMessage(MSG_START_COMPUTE_SCROLL);
  8. } else {
  9. cur_delay_times = ALL_DELAY_TIMES;
  10. }
  11. }
  12. return   false ;
  13. }

When the scrolling speed is greater than 5000, a recalculation message is sent. After the Handler receives the message, it delays for a period of time and continues to send messages to itself until the time runs out or the mChildView scrolls to the top or the user performs another Fling action.

  1. private Handler mHandler = new Handler() {
  2. @Override
  3. public void handleMessage(Message msg) {
  4. switch (msg.what) {
  5. case MSG_START_COMPUTE_SCROLL:
  6. cur_delay_times = -1; //There is no break here, so -1 is used for counting
  7. case MSG_CONTINUE_COMPUTE_SCROLL:
  8. cur_delay_times++;
  9.  
  10. if (!isRefreshing && !isLoadingmore && mVelocityY >= 5000 && childScrollToTop()) {
  11. animOverScrollTop();
  12. cur_delay_times = ALL_DELAY_TIMES;
  13. }
  14.  
  15. if (!isRefreshing && !isLoadingmore && mVelocityY <= -5000 && childScrollToBottom()) {
  16. animOverScrollBottom();
  17. cur_delay_times = ALL_DELAY_TIMES;
  18. }
  19.  
  20. if (cur_delay_times < ALL_DELAY_TIMES)
  21. mHandler.sendEmptyMessageDelayed(MSG_CONTINUE_COMPUTE_SCROLL, 10);
  22. break;
  23. case MSG_STOP_COMPUTE_SCROLL:
  24. cur_delay_times = ALL_DELAY_TIMES;
  25. break;
  26. }
  27. }
  28. };

ALL_DELAY_TIMES is the maximum number of times that can be calculated. When the Handler receives the MSG_START_COMPUTE_SCROLL message, if the mChildView has not scrolled to the boundary, it will send itself a MSG_CONTINUE_COMPUTE_SCROLL message after 10ms, and then continue to judge. Then, when the boundary is crossed, it will rebound.

10. Implement personalized header

Here I will demonstrate how to easily make a personalized Header, such as a Sina Weibo style refresh Header (as shown in the first picture below).

  1. Create SinaRefreshView inherited from FrameLayout and implement the IHeaderView interface
  2. Return this in the getView() method
  3. Get the required layout in the onAttachedToWindow() method (I wrote it in xml, or you can write it directly in the code)
    1. @Override
    2. protected void onAttachedToWindow() {
    3. super.onAttachedToWindow();
    4.  
    5. if (rootView == null ) {
    6. rootView = View .inflate(getContext(), R.layout.view_sinaheader, null );
    7. refreshArrow = (ImageView) rootView.findViewById(R.id.iv_arrow);
    8. refreshTextView = (TextView) rootView.findViewById(R.id.tv);
    9. loadingView = (ImageView) rootView.findViewById(R.id.iv_loading);
    10. addView(rootView);
    11. }
    12. }
  4. Implementing other methods
    1. @Override
    2. public void onPullingDown( float fraction, float maxHeadHeight, float headHeight) {
    3. if (fraction < 1f) refreshTextView.setText(pullDownStr);
    4. if (fraction > 1f) refreshTextView.setText(releaseRefreshStr);
    5. refreshArrow.setRotation(fraction * headHeight / maxHeadHeight * 180);
    6. }
    7.  
    8. @Override
    9. public void onPullReleasing( float fraction, float maxHeadHeight, float headHeight) {
    10. if (fraction < 1f) {
    11. refreshTextView.setText(pullDownStr);
    12. refreshArrow.setRotation(fraction * headHeight / maxHeadHeight * 180);
    13. if (refreshArrow.getVisibility() == GONE) {
    14. refreshArrow.setVisibility(VISIBLE);
    15. loadingView.setVisibility(GONE);
    16. }
    17. }
    18. }
    19.  
    20. @Override
    21. public void startAnim( float maxHeadHeight, float headHeight) {
    22. refreshTextView.setText(refreshingStr);
    23. refreshArrow.setVisibility(GONE);
    24. loadingView.setVisibility(VISIBLE);
    25. }
  5. Layout File
    1. <?xml version= "1.0" encoding= "utf-8" ?>
    2. <LinearLayout xmlns:android= "http://schemas.android.com/apk/res/android"  
    3. android:orientation= "horizontal" android:layout_width= "match_parent"  
    4. android:layout_height= "match_parent"  
    5. android:gravity= "center" >
    6. <ImageView
    7. android:id= "@+id/iv_arrow"  
    8. android:layout_width= "wrap_content"  
    9. android:layout_height= "wrap_content"  
    10. android:src= "@drawable/ic_arrow" />
    11.  
    12. <ImageView
    13. android:id= "@+id/iv_loading"  
    14. android:visibility= "gone"  
    15. android:layout_width= "34dp"  
    16. android:layout_height= "34dp"  
    17. android:src= "@drawable/anim_loading_view" />
    18.  
    19. <TextView
    20. android:id= "@+id/tv"  
    21. android:layout_width= "wrap_content"  
    22. android:layout_height= "wrap_content"  
    23. android:layout_marginLeft= "16dp"  
    24. android:textSize= "16sp"  
    25. android:text= "Pull down to refresh" />
    26. </LinearLayout>

Note the use of fractions, such as the code refreshArrow.setRotation(fraction headHeight / maxHeadHeight 180) above, fraction * headHeight represents the current head sliding distance, and then calculate its ratio to the maximum height, and then multiply it by 180, so that the Arrow can rotate exactly 180 degrees when sliding to the maximum distance. The startAnim() method is automatically called after onRefresh.

To achieve the effect shown in Figure 2, you can refer to the author's open source library TwinklingRefreshLayout.

Summarize

So far, I have finished talking about all the core ideas of implementing this refresh control. No advanced technology is used in it. It just requires us to be more patient, debug more, don't avoid bugs, and challenge ourselves more.

<<:  21 questions and answers about Android View event mechanism

>>:  Android Security: Intent Scheme Url Attack

Recommend

Is ocean exploration really that difficult?

With the continuous development of science and te...

Ten ways to reverse your financial thinking

There is a fine line between going against the cu...

How to grill elegantly: Why does the smoke always chase me?

Author: New Media Center of Institute of Physics,...

360 advertising promotion costs, billing methods, and click prices!

What is the billing method for 360 search promoti...

How to choose a good Internet marketing promotion company?​

The Internet continues to develop, and online mar...

How does Starbucks build a membership growth system?

In the morning, I took the curriculum development...

Summary of the use of global variables and local variables in Android

As the name implies, global variables are variabl...

How can new media create year-end promotional hits?

In order to give some inspiration to colleagues w...

The “knowns” and “unknowns” about asymptomatic infections

"Asymptomatic infections" have become a...

How much does it cost to join the Jiaozuo Education Mini Program?

How much does it cost to join the Jiaozuo educati...