Last active
March 2, 2016 14:41
-
-
Save clowestab/41ae46fca818f27f3209 to your computer and use it in GitHub Desktop.
A simple but complete Android pull to refresh/load more ListView implementation.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
package com.package | |
import android.animation.Animator; | |
import android.animation.ObjectAnimator; | |
import android.content.Context; | |
import android.util.AttributeSet; | |
import android.util.Log; | |
import android.view.LayoutInflater; | |
import android.view.MotionEvent; | |
import android.view.View; | |
import android.view.ViewGroup; | |
import android.view.ViewTreeObserver; | |
import android.view.animation.LinearInterpolator; | |
import android.view.animation.RotateAnimation; | |
import android.widget.ImageView; | |
import android.widget.LinearLayout; | |
import android.widget.ListView; | |
import android.widget.ProgressBar; | |
import android.widget.RelativeLayout; | |
import android.widget.TextView; | |
/** | |
* Created by thomasclowes on 28/11/2014. | |
*/ | |
public class PRListView extends ListView { | |
private static final float PULL_RESISTANCE = 1.7f; | |
private static final int ROTATE_ARROW_ANIMATION_DURATION = 250; | |
private static enum RefreshState{ | |
PULL_TO_REFRESH, | |
RELEASE_TO_REFRESH, | |
REFRESHING | |
} | |
private static enum LoadMoreState{ | |
SCROLL_TO_LOAD_MORE, | |
RELEASE_TO_LOAD_MORE, | |
LOADING_MORE | |
} | |
public PRListView(Context context) { | |
super(context); | |
init(); | |
} | |
public PRListView(Context context, AttributeSet attrs){ | |
super(context, attrs); | |
init(); | |
} | |
//Will hold the heights of our header/footer so we don't have to requery it | |
private static int measuredHeaderHeight; | |
private static int measuredFooterHeight; | |
//The text views in which state messages are placed | |
TextView headerText; | |
TextView footerText; | |
//The text for the header in different states | |
private String pullToRefreshText = "Pull to refresh"; | |
private String releaseToRefreshText = "Release to refresh"; | |
private String refreshingText = "Refreshing.."; | |
//The text for the footer in different states | |
private String scrollToLoadMoreText = "Scroll down to load more"; | |
private String releaseToLoadMoreText = "Release to load more"; | |
private String loadingMoreText = "Loading more.."; | |
//Indicates if we have setup the footer.. | |
Boolean footerSetup = false; | |
//Saves a reference to the last scroll y position | |
private float previousY; | |
//The header | |
private int headerPadding; | |
private RefreshState refreshState; | |
private LinearLayout headerContainer; | |
private RelativeLayout header; | |
//The footer | |
private int footerPadding; | |
private LoadMoreState loadMoreState; | |
private LinearLayout footerContainer; | |
private RelativeLayout footer; | |
//The animation definitions for spinning the image | |
private RotateAnimation flipAnimation; | |
private RotateAnimation reverseFlipAnimation; | |
//The image/spinner for the header/footer | |
private ImageView headerImage; | |
private ProgressBar headerSpinner; | |
private ImageView footerImage; | |
private ProgressBar footerSpinner; | |
//Listeners for doing the refresh/ load more | |
private OnRefreshListener onRefreshListener; | |
private OnLoadMoreListener onLoadMoreListener; | |
//The current page of data being shown | |
public Integer page = 1; | |
//Set up the refresh control state values | |
public void setUpRefreshControl(String pullText, String releaseText, String refreshing) { | |
pullToRefreshText = pullText; | |
releaseToRefreshText = releaseText; | |
refreshingText = refreshing; | |
} | |
//Set up the load more control state values | |
public void setUpLoadMoreControl(String scrollText, String releaseText, String loadingText) { | |
scrollToLoadMoreText = scrollText; | |
releaseToLoadMoreText = releaseText; | |
loadingMoreText = loadingText; | |
} | |
private void init(){ | |
//Defines which edges should be faded on scrolling. | |
setVerticalFadingEdgeEnabled(false); | |
//Inflate the header and save the references | |
headerContainer = (LinearLayout) LayoutInflater.from(getContext()).inflate(R.layout.ptr_header, null); | |
header = (RelativeLayout) headerContainer.findViewById(R.id.ptr_id_header); | |
headerText = (TextView) header.findViewById(R.id.ptr_id_text); | |
headerImage = (ImageView) header.findViewById(R.id.ptr_id_image); | |
headerSpinner = (ProgressBar) header.findViewById(R.id.ptr_id_spinner); | |
//We can reuse these animations for both header and footer | |
flipAnimation = new RotateAnimation(0, -180, RotateAnimation.RELATIVE_TO_SELF, 0.5f, RotateAnimation.RELATIVE_TO_SELF, 0.5f); | |
flipAnimation.setInterpolator(new LinearInterpolator()); | |
flipAnimation.setDuration(ROTATE_ARROW_ANIMATION_DURATION); | |
flipAnimation.setFillAfter(true); | |
reverseFlipAnimation = new RotateAnimation(-180, 0, RotateAnimation.RELATIVE_TO_SELF, 0.5f, RotateAnimation.RELATIVE_TO_SELF, 0.5f); | |
reverseFlipAnimation.setInterpolator(new LinearInterpolator()); | |
reverseFlipAnimation.setDuration(ROTATE_ARROW_ANIMATION_DURATION); | |
reverseFlipAnimation.setFillAfter(true); | |
//Add the header and set its initial state | |
addHeaderView(headerContainer); | |
setRefreshState(RefreshState.PULL_TO_REFRESH); | |
//Inflate the footer and save the references | |
footerContainer = (LinearLayout) LayoutInflater.from(getContext()).inflate(R.layout.load_more_control, null); | |
footer = (RelativeLayout) footerContainer.findViewById(R.id.ptr_id_header); | |
footerText = (TextView) footer.findViewById(R.id.ptr_id_text); | |
footerImage = (ImageView) footer.findViewById(R.id.ptr_id_image); | |
footerSpinner = (ProgressBar) footer.findViewById(R.id.ptr_id_spinner); | |
//Add the footer and set its initial state | |
addFooterView(footerContainer); | |
setLoadMoreState(LoadMoreState.SCROLL_TO_LOAD_MORE); | |
//This VTO makes sure the refresh control is not displayed at first | |
ViewTreeObserver vto = header.getViewTreeObserver(); | |
vto.addOnGlobalLayoutListener(new PTROnGlobalLayoutListener()); | |
} | |
//The pull to refresh effect is accomplished by increasing the top padding of the header view so that it appears that you are pulling the list down | |
private void setHeaderPadding(int padding){ | |
headerPadding = padding; | |
MarginLayoutParams mlp = (ViewGroup.MarginLayoutParams) header.getLayoutParams(); | |
//left top right bottom | |
mlp.setMargins(0, Math.round(padding), 0, 0); | |
header.setLayoutParams(mlp); | |
} | |
//Bounce back the header to the appropriate place | |
private void bounceBackHeader(){ | |
int yTranslation; | |
int newPadding; | |
int previousPadding = headerPadding; | |
//Container height is the height - the padding so header height - container height = padding | |
if (refreshState == RefreshState.REFRESHING) { | |
yTranslation = header.getHeight() - headerContainer.getHeight(); | |
newPadding = 0; | |
} else { | |
yTranslation = -headerContainer.getHeight() - headerContainer.getTop() + getPaddingTop(); | |
newPadding = -measuredHeaderHeight; | |
} | |
final int yTranslate = yTranslation; | |
final int newPad = newPadding; | |
//Proxy the header margin so it can be used with object animator | |
MarginProxy mp = new MarginProxy(header); | |
ObjectAnimator mover = ObjectAnimator.ofInt(mp, "topMargin", newPad); | |
mover.setDuration(400); | |
mover.addListener(new Animator.AnimatorListener() { | |
@Override | |
public void onAnimationStart(Animator animator) { | |
} | |
@Override | |
public void onAnimationEnd(Animator animator) { | |
//We have this because if you scroll again it flashes back to the previous padding | |
//I believe this may be to do with how android does animations with bitmaps etc | |
setHeaderPadding(newPad); | |
} | |
@Override | |
public void onAnimationCancel(Animator animator) { | |
} | |
@Override | |
public void onAnimationRepeat(Animator animator) { | |
} | |
}); | |
mover.start(); | |
} | |
//The scroll to load more effect is accomplished by increasing the bottom padding of the header view so that it appears that you are scrolling further than is possible | |
public void setFooterPadding(int padding){ | |
footerPadding = padding; | |
MarginLayoutParams mlp = (ViewGroup.MarginLayoutParams) header.getLayoutParams(); | |
//left top right bottom | |
mlp.setMargins(0, 0, 0, Math.round(padding)); | |
footer.setLayoutParams(mlp); | |
} | |
//Bounces back to the appropriate place | |
private void bounceBackFooter(){ | |
int yTranslation; | |
int newPadding; | |
if (loadMoreState == LoadMoreState.LOADING_MORE) { | |
yTranslation = -(footer.getHeight() - footerContainer.getHeight()); | |
newPadding = 0; | |
} else { | |
yTranslation = footerContainer.getHeight(); | |
newPadding = -measuredFooterHeight; | |
} | |
final int yTranslate = yTranslation; | |
final int newPad = newPadding; | |
//Proxy the footer margin so it can be used with object animator | |
MarginProxy mp = new MarginProxy(footer); | |
ObjectAnimator mover = ObjectAnimator.ofInt(mp, "bottomMargin", newPad); | |
mover.setDuration(400); | |
mover.addListener(new Animator.AnimatorListener() { | |
@Override | |
public void onAnimationStart(Animator animator) { | |
} | |
@Override | |
public void onAnimationEnd(Animator animator) { | |
//We have this because if you scroll again it flashes back to the previous padding | |
//I believe this may be to do with how android does animations with bitmaps etc | |
setFooterPadding(newPad); | |
} | |
@Override | |
public void onAnimationCancel(Animator animator) { | |
} | |
@Override | |
public void onAnimationRepeat(Animator animator) { | |
} | |
}); | |
mover.start(); | |
} | |
//This is required to intercept touch down events | |
@Override | |
public boolean onInterceptTouchEvent(MotionEvent ev) { | |
return true; | |
} | |
@Override | |
public boolean onTouchEvent(MotionEvent event){ | |
//The current y coordinate | |
float y = event.getY(); | |
switch(event.getAction()){ | |
//When we first touch down | |
case MotionEvent.ACTION_DOWN: | |
previousY = y; | |
break; | |
//When we let go of scroll | |
case MotionEvent.ACTION_UP: | |
if (loadMoreState == LoadMoreState.RELEASE_TO_LOAD_MORE || getLastVisiblePosition() == getCount() - 1) { | |
switch (loadMoreState) { | |
case RELEASE_TO_LOAD_MORE: | |
setLoadMoreState(LoadMoreState.LOADING_MORE); | |
bounceBackFooter(); | |
break; | |
case SCROLL_TO_LOAD_MORE: | |
bounceBackFooter(); | |
break; | |
} | |
} | |
else if (refreshState == RefreshState.RELEASE_TO_REFRESH || getFirstVisiblePosition() == 0) { | |
switch(refreshState){ | |
case RELEASE_TO_REFRESH: | |
setRefreshState(RefreshState.REFRESHING); | |
bounceBackHeader(); | |
break; | |
case PULL_TO_REFRESH: | |
bounceBackHeader(); | |
break; | |
} | |
} | |
break; | |
case MotionEvent.ACTION_MOVE: | |
//y goes up as you scroll up | |
if (getFirstVisiblePosition() == 0){ | |
float diff = y - previousY; | |
//resistance is simulated my only adding padding that is a percentage of the amount they actually moved | |
if(diff > 0) diff /= PULL_RESISTANCE; | |
//Header showing is normal. No padding. | |
//+ padding = extra space at the top | |
//- padding is equivalent to it being hidden | |
//if the negative padding equals its height it is totally hidden.. | |
//when not enough -18, -180 for example | |
int potentialNewHeaderPadding = Math.round(headerPadding + diff); | |
//if the potential is less negative than -height then some of the header IS showing | |
int newHeaderPadding = Math.max(potentialNewHeaderPadding, -header.getHeight()); | |
//Only show this 'over scroll' if not already refreshing | |
//newHeaderPadding != headerPadding essentially checks we are actually moving i think | |
if (newHeaderPadding != headerPadding && refreshState != RefreshState.REFRESHING){ | |
setHeaderPadding(newHeaderPadding); | |
//if padding > 0 we are now over scrolling | |
if (refreshState == RefreshState.PULL_TO_REFRESH && headerPadding > 0){ | |
setRefreshState(RefreshState.RELEASE_TO_REFRESH); | |
//Animate the image flipping | |
headerImage.clearAnimation(); | |
headerImage.startAnimation(flipAnimation); | |
//If it is less than 0 we are partially showing | |
} else if (refreshState == RefreshState.RELEASE_TO_REFRESH && headerPadding < 0){ | |
setRefreshState(RefreshState.PULL_TO_REFRESH); | |
headerImage.clearAnimation(); | |
headerImage.startAnimation(reverseFlipAnimation); | |
} | |
} | |
//load more control | |
} else if (getLastVisiblePosition() == getCount() - 1) { | |
if (!footerSetup) { | |
//The header is set up in the global listener.. we set up the footer the first time in needs considering | |
int initialFooterHeight = footer.getHeight(); | |
measuredFooterHeight = initialFooterHeight; | |
setFooterPadding(-initialFooterHeight); | |
footerSetup = true; | |
} | |
float diff = y - previousY; | |
//resistance is simulated by only adding padding that is a percentage of the amount they actually moved | |
if (diff < 0) diff /= PULL_RESISTANCE; | |
//Choose the max either the default or the calculated | |
//if its a - diff -- = +.. | |
int potentialNewFooterPadding = Math.round(footerPadding - diff); | |
int newFooterPadding = Math.max(potentialNewFooterPadding, -measuredFooterHeight); | |
//Only show this 'over scroll' if not already refreshing | |
//and if the padding has changed | |
if (newFooterPadding != footerPadding && loadMoreState != LoadMoreState.LOADING_MORE) { | |
setFooterPadding(newFooterPadding); | |
if (loadMoreState == LoadMoreState.SCROLL_TO_LOAD_MORE && footerPadding > 0) { | |
setLoadMoreState(LoadMoreState.RELEASE_TO_LOAD_MORE); | |
//Animate the image flipping | |
footerImage.clearAnimation(); | |
footerImage.startAnimation(flipAnimation); | |
} else if (loadMoreState == LoadMoreState.RELEASE_TO_LOAD_MORE && footerPadding < 0) { | |
setLoadMoreState(LoadMoreState.SCROLL_TO_LOAD_MORE); | |
footerImage.clearAnimation(); | |
footerImage.startAnimation(reverseFlipAnimation); | |
} | |
} | |
} | |
//Always keep track of the last y coordinate while scrolling | |
previousY = y; | |
break; | |
} | |
return super.onTouchEvent(event); | |
} | |
//This method sets the state text, spinner visibility etc | |
//Also tells listener to do refreshing if in refresh state | |
private void setRefreshState(RefreshState state){ | |
this.refreshState = state; | |
switch(state){ | |
//Whilst we have to pull to refresh, no spinner visible | |
case PULL_TO_REFRESH: | |
headerSpinner.setVisibility(View.INVISIBLE); | |
headerImage.setVisibility(View.VISIBLE); | |
headerText.setText(pullToRefreshText); | |
break; | |
//Release state still no spinner | |
case RELEASE_TO_REFRESH: | |
headerSpinner.setVisibility(View.INVISIBLE); | |
headerImage.setVisibility(View.VISIBLE); | |
headerText.setText(releaseToRefreshText); | |
break; | |
//Refreshing - show spinner and tell listener to do refreshing | |
case REFRESHING: | |
//When a refresh has been triggered, hide image and show spinner/refreshing text | |
headerSpinner.setVisibility(View.VISIBLE); | |
headerImage.clearAnimation(); | |
headerImage.setVisibility(View.INVISIBLE); | |
headerText.setText(refreshingText); | |
//If no listener the refresh is complete instantly.. | |
if (onRefreshListener == null){ | |
Log.w("Notice", "You have not implemented an onRefreshListener"); | |
setRefreshState(RefreshState.PULL_TO_REFRESH); | |
//If there is a listener.. do the refresh and then tell the list view when its done | |
} else { | |
onRefreshListener.onRefresh(); | |
} | |
break; | |
} | |
} | |
//This method sets the state text, spinner visibility etc | |
//Also tells listener to load more if in load more state | |
private void setLoadMoreState(LoadMoreState state){ | |
this.loadMoreState = state; | |
switch(loadMoreState){ | |
//Whilst we have to pull to refresh, no spinner visible | |
case SCROLL_TO_LOAD_MORE: | |
footerSpinner.setVisibility(View.INVISIBLE); | |
footerImage.setVisibility(View.VISIBLE); | |
footerText.setText(scrollToLoadMoreText); | |
break; | |
//Release state still no spinner | |
case RELEASE_TO_LOAD_MORE: | |
footerSpinner.setVisibility(View.INVISIBLE); | |
footerImage.setVisibility(View.VISIBLE); | |
footerText.setText(releaseToLoadMoreText); | |
break; | |
//Refreshing - show spinner and tell listener to do refreshing | |
case LOADING_MORE: | |
//When a refresh has been triggered, hide image and show spinner/refreshing text | |
footerSpinner.setVisibility(View.VISIBLE); | |
footerImage.clearAnimation(); | |
footerImage.setVisibility(View.INVISIBLE); | |
footerText.setText(loadingMoreText); | |
//If no listener the refresh is complete instantly.. | |
if (onLoadMoreListener == null){ | |
Log.w("Notice", "You have not implemented an onLoadMoreListener"); | |
setLoadMoreState(LoadMoreState.SCROLL_TO_LOAD_MORE); | |
//If there is a listener.. do the refresh and then tell the list view when its done | |
} else { | |
onLoadMoreListener.onLoadMore(); | |
} | |
break; | |
} | |
} | |
public interface OnRefreshListener{ | |
public void onRefresh(); | |
} | |
public interface OnLoadMoreListener{ | |
public void onLoadMore(); | |
} | |
//Call this once you have finished refreshing data in listener | |
public void onRefreshComplete(){ | |
setRefreshState(RefreshState.PULL_TO_REFRESH); | |
bounceBackHeader(); | |
} | |
//Call this once you have finished loading more in listener | |
public void onLoadMoreComplete(){ | |
setLoadMoreState(LoadMoreState.SCROLL_TO_LOAD_MORE); | |
bounceBackFooter(); | |
} | |
//Define how you will refresh the data | |
public void setOnRefreshListener(OnRefreshListener onRefreshListener){ | |
this.onRefreshListener = onRefreshListener; | |
} | |
//Define how you will load more data | |
public void setOnLoadMoreListener(OnLoadMoreListener onLoadMoreListener){ | |
this.onLoadMoreListener = onLoadMoreListener; | |
} | |
//This listener makes sure that the Pull to Refresh control is hidden at the start. | |
private class PTROnGlobalLayoutListener implements ViewTreeObserver.OnGlobalLayoutListener{ | |
@Override | |
public void onGlobalLayout(){ | |
int initialHeaderHeight = header.getHeight(); | |
if(initialHeaderHeight > 0){ | |
measuredHeaderHeight = initialHeaderHeight; | |
if(measuredHeaderHeight > 0 && refreshState != RefreshState.REFRESHING){ | |
setHeaderPadding(-measuredHeaderHeight); | |
//smoothScrollToPosition(1); | |
requestLayout(); | |
} | |
} | |
Helpers.removeOnGlobalLayoutListener(getViewTreeObserver(), this); | |
} | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Hi Thomas,
where is the MarginProxy Class ?
where is the Helper Class?
can you please share complete demo?
Thanks
Regards
Lalit