diff --git a/build.gradle b/build.gradle index cdfb0477..0b3b3ba1 100644 --- a/build.gradle +++ b/build.gradle @@ -23,8 +23,8 @@ subprojects { mavenCentral() } dependencies { - classpath 'com.android.tools.build:gradle:1.5.0' - classpath 'org.kt3k.gradle.plugin:coveralls-gradle-plugin:2.1.0' + classpath 'com.android.tools.build:gradle:2.1.2' + classpath 'org.kt3k.gradle.plugin:coveralls-gradle-plugin:2.6.3' } } } diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index e7faee01..9b299e92 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ -#Wed Apr 10 15:27:10 PDT 2013 +#Tue Jul 19 18:40:39 KST 2016 distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-2.4-all.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-2.10-all.zip diff --git a/library/build.gradle b/library/build.gradle index a1bede3c..68ac4924 100644 --- a/library/build.gradle +++ b/library/build.gradle @@ -20,7 +20,7 @@ android { buildTypes { debug { - testCoverageEnabled = true + testCoverageEnabled = false } } diff --git a/library/src/androidTest/AndroidManifest.xml b/library/src/androidTest/AndroidManifest.xml index d8476e95..d014aea5 100644 --- a/library/src/androidTest/AndroidManifest.xml +++ b/library/src/androidTest/AndroidManifest.xml @@ -26,6 +26,7 @@ + diff --git a/library/src/androidTest/java/com/github/ksoichiro/android/observablescrollview/test/NestedScrollViewActivity.java b/library/src/androidTest/java/com/github/ksoichiro/android/observablescrollview/test/NestedScrollViewActivity.java new file mode 100644 index 00000000..9471eff7 --- /dev/null +++ b/library/src/androidTest/java/com/github/ksoichiro/android/observablescrollview/test/NestedScrollViewActivity.java @@ -0,0 +1,30 @@ +package com.github.ksoichiro.android.observablescrollview.test; + +import android.app.Activity; +import android.os.Bundle; + +import com.github.ksoichiro.android.observablescrollview.ObservableScrollViewCallbacks; +import com.github.ksoichiro.android.observablescrollview.ScrollState; +import com.github.ksoichiro.android.observablescrollview.Scrollable; + +public class NestedScrollViewActivity extends Activity implements ObservableScrollViewCallbacks { + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + setContentView(R.layout.activity_nestedscrollview); + ((Scrollable) findViewById(R.id.scrollable)).setScrollViewCallbacks(this); + } + + @Override + public void onScrollChanged(int scrollY, boolean firstScroll, boolean dragging) { + } + + @Override + public void onDownMotionEvent() { + } + + @Override + public void onUpOrCancelMotionEvent(ScrollState scrollState) { + } +} diff --git a/library/src/androidTest/java/com/github/ksoichiro/android/observablescrollview/test/NestedScrollViewActivityTest.java b/library/src/androidTest/java/com/github/ksoichiro/android/observablescrollview/test/NestedScrollViewActivityTest.java new file mode 100644 index 00000000..f8f0d71d --- /dev/null +++ b/library/src/androidTest/java/com/github/ksoichiro/android/observablescrollview/test/NestedScrollViewActivityTest.java @@ -0,0 +1,135 @@ +package com.github.ksoichiro.android.observablescrollview.test; + +import android.app.Activity; +import android.test.ActivityInstrumentationTestCase2; + +import com.github.ksoichiro.android.observablescrollview.ObservableNestedScrollView; +import com.github.ksoichiro.android.observablescrollview.ObservableScrollView; +import com.github.ksoichiro.android.observablescrollview.ObservableScrollViewCallbacks; +import com.github.ksoichiro.android.observablescrollview.ScrollState; + +public class NestedScrollViewActivityTest extends ActivityInstrumentationTestCase2 { + + private Activity activity; + private ObservableNestedScrollView scrollable; + private int[] callbackCounter; + + public NestedScrollViewActivityTest() { + super(NestedScrollViewActivity.class); + } + + @Override + protected void setUp() throws Exception { + super.setUp(); + setActivityInitialTouchMode(true); + activity = getActivity(); + scrollable = (ObservableNestedScrollView) activity.findViewById(R.id.scrollable); + callbackCounter = new int[2]; + } + + public void testInitialize() throws Throwable { + runTestOnUiThread(new Runnable() { + @Override + public void run() { + new ObservableNestedScrollView(activity); + new ObservableNestedScrollView(activity, null, 0); + } + }); + } + + public void testScroll() throws Throwable { + UiTestUtils.swipeVertically(this, scrollable, UiTestUtils.Direction.UP); + getInstrumentation().waitForIdleSync(); + + UiTestUtils.swipeVertically(this, scrollable, UiTestUtils.Direction.DOWN); + getInstrumentation().waitForIdleSync(); + } + + public void testSaveAndRestoreInstanceState() throws Throwable { + UiTestUtils.saveAndRestoreInstanceState(this, activity); + testScroll(); + } + + public void testNoCallbacks() throws Throwable { + runTestOnUiThread(new Runnable() { + @Override + public void run() { + scrollable = (ObservableNestedScrollView) activity.findViewById(R.id.scrollable); + scrollable.setScrollViewCallbacks(null); + } + }); + testScroll(); + } + + public void testCallbacks() throws Throwable { + final ObservableScrollViewCallbacks[] callbacks = new ObservableScrollViewCallbacks[2]; + callbackCounter[0] = 0; + callbackCounter[1] = 0; + runTestOnUiThread(new Runnable() { + @Override + public void run() { + scrollable = (ObservableNestedScrollView) activity.findViewById(R.id.scrollable); + callbacks[0] = new ObservableScrollViewCallbacks() { + @Override + public void onScrollChanged(int scrollY, boolean firstScroll, boolean dragging) { + callbackCounter[0]++; + } + + @Override + public void onDownMotionEvent() { + } + + @Override + public void onUpOrCancelMotionEvent(ScrollState scrollState) { + } + }; + scrollable.addScrollViewCallbacks(callbacks[0]); + callbacks[1] = new ObservableScrollViewCallbacks() { + @Override + public void onScrollChanged(int scrollY, boolean firstScroll, boolean dragging) { + callbackCounter[1]++; + } + + @Override + public void onDownMotionEvent() { + } + + @Override + public void onUpOrCancelMotionEvent(ScrollState scrollState) { + } + }; + scrollable.addScrollViewCallbacks(callbacks[1]); + } + }); + testScroll(); + // Assert that all the callbacks are enabled and get called. + assertTrue(0 < callbackCounter[0]); + assertTrue(0 < callbackCounter[1]); + + // Remove one of the callbacks and scroll again to assert it's really removed. + runTestOnUiThread(new Runnable() { + @Override + public void run() { + scrollable.removeScrollViewCallbacks(callbacks[0]); + } + }); + callbackCounter[0] = 0; + callbackCounter[1] = 0; + testScroll(); + assertTrue(0 == callbackCounter[0]); + assertTrue(0 < callbackCounter[1]); + + // Clear all callbacks and assert they're really removed. + runTestOnUiThread(new Runnable() { + @Override + public void run() { + scrollable.clearScrollViewCallbacks(); + } + }); + callbackCounter[0] = 0; + callbackCounter[1] = 0; + testScroll(); + assertTrue(0 == callbackCounter[0]); + assertTrue(0 == callbackCounter[1]); + } +} diff --git a/library/src/androidTest/res/layout/activity_nestedscrollview.xml b/library/src/androidTest/res/layout/activity_nestedscrollview.xml new file mode 100644 index 00000000..7e4d53f1 --- /dev/null +++ b/library/src/androidTest/res/layout/activity_nestedscrollview.xml @@ -0,0 +1,28 @@ + + + + + + diff --git a/library/src/main/java/com/github/ksoichiro/android/observablescrollview/ObservableNestedScrollView.java b/library/src/main/java/com/github/ksoichiro/android/observablescrollview/ObservableNestedScrollView.java new file mode 100644 index 00000000..dbc4100a --- /dev/null +++ b/library/src/main/java/com/github/ksoichiro/android/observablescrollview/ObservableNestedScrollView.java @@ -0,0 +1,305 @@ +package com.github.ksoichiro.android.observablescrollview; + +import android.content.Context; +import android.os.Parcel; +import android.os.Parcelable; +import android.support.v4.widget.NestedScrollView; +import android.util.AttributeSet; +import android.view.MotionEvent; +import android.view.View; +import android.view.ViewGroup; + +import java.util.ArrayList; +import java.util.List; + +/** + * NestedScrollView that its scroll position can be observed. + */ +public class ObservableNestedScrollView extends NestedScrollView implements Scrollable { + + // Fields that should be saved onSaveInstanceState + private int mPrevScrollY; + private int mScrollY; + + // Fields that don't need to be saved onSaveInstanceState + private ObservableScrollViewCallbacks mCallbacks; + private List mCallbackCollection; + private ScrollState mScrollState; + private boolean mFirstScroll; + private boolean mDragging; + private boolean mIntercepted; + private MotionEvent mPrevMoveEvent; + private ViewGroup mTouchInterceptionViewGroup; + + public ObservableNestedScrollView(Context context) { + super(context); + } + + public ObservableNestedScrollView(Context context, AttributeSet attrs) { + super(context, attrs); + } + + public ObservableNestedScrollView(Context context, AttributeSet attrs, int defStyle) { + super(context, attrs, defStyle); + } + + @Override + public void onRestoreInstanceState(Parcelable state) { + SavedState ss = (SavedState) state; + mPrevScrollY = ss.prevScrollY; + mScrollY = ss.scrollY; + super.onRestoreInstanceState(ss.getSuperState()); + } + + @Override + public Parcelable onSaveInstanceState() { + Parcelable superState = super.onSaveInstanceState(); + SavedState ss = new SavedState(superState); + ss.prevScrollY = mPrevScrollY; + ss.scrollY = mScrollY; + return ss; + } + + @Override + protected void onScrollChanged(int l, int t, int oldl, int oldt) { + super.onScrollChanged(l, t, oldl, oldt); + if (hasNoCallbacks()) { + return; + } + mScrollY = t; + + dispatchOnScrollChanged(t, mFirstScroll, mDragging); + if (mFirstScroll) { + mFirstScroll = false; + } + + if (mPrevScrollY < t) { + mScrollState = ScrollState.UP; + } else if (t < mPrevScrollY) { + mScrollState = ScrollState.DOWN; + //} else { + // Keep previous state while dragging. + // Never makes it STOP even if scrollY not changed. + // Before Android 4.4, onTouchEvent calls onScrollChanged directly for ACTION_MOVE, + // which makes mScrollState always STOP when onUpOrCancelMotionEvent is called. + // STOP state is now meaningless for ScrollView. + } + mPrevScrollY = t; + } + + @Override + public boolean onInterceptTouchEvent(MotionEvent ev) { + if (hasNoCallbacks()) { + return super.onInterceptTouchEvent(ev); + } + switch (ev.getActionMasked()) { + case MotionEvent.ACTION_DOWN: + // Whether or not motion events are consumed by children, + // flag initializations which are related to ACTION_DOWN events should be executed. + // Because if the ACTION_DOWN is consumed by children and only ACTION_MOVEs are + // passed to parent (this view), the flags will be invalid. + // Also, applications might implement initialization codes to onDownMotionEvent, + // so call it here. + mFirstScroll = mDragging = true; + dispatchOnDownMotionEvent(); + break; + } + return super.onInterceptTouchEvent(ev); + } + + @Override + public boolean onTouchEvent(MotionEvent ev) { + if (hasNoCallbacks()) { + return super.onTouchEvent(ev); + } + switch (ev.getActionMasked()) { + case MotionEvent.ACTION_UP: + case MotionEvent.ACTION_CANCEL: + mIntercepted = false; + mDragging = false; + dispatchOnUpOrCancelMotionEvent(mScrollState); + break; + case MotionEvent.ACTION_MOVE: + if (mPrevMoveEvent == null) { + mPrevMoveEvent = ev; + } + float diffY = ev.getY() - mPrevMoveEvent.getY(); + mPrevMoveEvent = MotionEvent.obtainNoHistory(ev); + if (getCurrentScrollY() - diffY <= 0) { + // Can't scroll anymore. + + if (mIntercepted) { + // Already dispatched ACTION_DOWN event to parents, so stop here. + return false; + } + + // Apps can set the interception target other than the direct parent. + final ViewGroup parent; + if (mTouchInterceptionViewGroup == null) { + parent = (ViewGroup) getParent(); + } else { + parent = mTouchInterceptionViewGroup; + } + + // Get offset to parents. If the parent is not the direct parent, + // we should aggregate offsets from all of the parents. + float offsetX = 0; + float offsetY = 0; + for (View v = this; v != null && v != parent; v = (View) v.getParent()) { + offsetX += v.getLeft() - v.getScrollX(); + offsetY += v.getTop() - v.getScrollY(); + } + final MotionEvent event = MotionEvent.obtainNoHistory(ev); + event.offsetLocation(offsetX, offsetY); + + if (parent.onInterceptTouchEvent(event)) { + mIntercepted = true; + + // If the parent wants to intercept ACTION_MOVE events, + // we pass ACTION_DOWN event to the parent + // as if these touch events just have began now. + event.setAction(MotionEvent.ACTION_DOWN); + + // Return this onTouchEvent() first and set ACTION_DOWN event for parent + // to the queue, to keep events sequence. + post(new Runnable() { + @Override + public void run() { + parent.dispatchTouchEvent(event); + } + }); + return false; + } + // Even when this can't be scrolled anymore, + // simply returning false here may cause subView's click, + // so delegate it to super. + return super.onTouchEvent(ev); + } + break; + } + return super.onTouchEvent(ev); + } + + @Override + public void setScrollViewCallbacks(ObservableScrollViewCallbacks listener) { + mCallbacks = listener; + } + + @Override + public void addScrollViewCallbacks(ObservableScrollViewCallbacks listener) { + if (mCallbackCollection == null) { + mCallbackCollection = new ArrayList<>(); + } + mCallbackCollection.add(listener); + } + + @Override + public void removeScrollViewCallbacks(ObservableScrollViewCallbacks listener) { + if (mCallbackCollection != null) { + mCallbackCollection.remove(listener); + } + } + + @Override + public void clearScrollViewCallbacks() { + if (mCallbackCollection != null) { + mCallbackCollection.clear(); + } + } + + @Override + public void setTouchInterceptionViewGroup(ViewGroup viewGroup) { + mTouchInterceptionViewGroup = viewGroup; + } + + @Override + public void scrollVerticallyTo(int y) { + scrollTo(0, y); + } + + @Override + public int getCurrentScrollY() { + return mScrollY; + } + + private void dispatchOnDownMotionEvent() { + if (mCallbacks != null) { + mCallbacks.onDownMotionEvent(); + } + if (mCallbackCollection != null) { + for (int i = 0; i < mCallbackCollection.size(); i++) { + ObservableScrollViewCallbacks callbacks = mCallbackCollection.get(i); + callbacks.onDownMotionEvent(); + } + } + } + + private void dispatchOnScrollChanged(int scrollY, boolean firstScroll, boolean dragging) { + if (mCallbacks != null) { + mCallbacks.onScrollChanged(scrollY, firstScroll, dragging); + } + if (mCallbackCollection != null) { + for (int i = 0; i < mCallbackCollection.size(); i++) { + ObservableScrollViewCallbacks callbacks = mCallbackCollection.get(i); + callbacks.onScrollChanged(scrollY, firstScroll, dragging); + } + } + } + + private void dispatchOnUpOrCancelMotionEvent(ScrollState scrollState) { + if (mCallbacks != null) { + mCallbacks.onUpOrCancelMotionEvent(scrollState); + } + if (mCallbackCollection != null) { + for (int i = 0; i < mCallbackCollection.size(); i++) { + ObservableScrollViewCallbacks callbacks = mCallbackCollection.get(i); + callbacks.onUpOrCancelMotionEvent(scrollState); + } + } + } + + private boolean hasNoCallbacks() { + return mCallbacks == null && mCallbackCollection == null; + } + + static class SavedState extends BaseSavedState { + int prevScrollY; + int scrollY; + + /** + * Called by onSaveInstanceState. + */ + SavedState(Parcelable superState) { + super(superState); + } + + /** + * Called by CREATOR. + */ + private SavedState(Parcel in) { + super(in); + prevScrollY = in.readInt(); + scrollY = in.readInt(); + } + + @Override + public void writeToParcel(Parcel out, int flags) { + super.writeToParcel(out, flags); + out.writeInt(prevScrollY); + out.writeInt(scrollY); + } + + public static final Parcelable.Creator CREATOR + = new Parcelable.Creator() { + @Override + public SavedState createFromParcel(Parcel in) { + return new SavedState(in); + } + + @Override + public SavedState[] newArray(int size) { + return new SavedState[size]; + } + }; + } +}