/* * Copyright 2014 Soichiro Kashima * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.github.ksoichiro.android.observablescrollview; import android.content.Context; import android.os.Parcel; import android.os.Parcelable; import android.util.AttributeSet; import android.util.SparseIntArray; import android.view.MotionEvent; import android.view.View; import android.view.ViewGroup; import android.widget.AbsListView; import android.widget.ListView; /** * ListView that its scroll position can be observed. */ public class ObservableListView extends ListView implements Scrollable { // Fields that should be saved onSaveInstanceState private int mPrevFirstVisiblePosition; private int mPrevFirstVisibleChildHeight = -1; private int mPrevScrolledChildrenHeight; private int mPrevScrollY; private int mScrollY; private SparseIntArray mChildrenHeights; // Fields that don't need to be saved onSaveInstanceState private ObservableScrollViewCallbacks mCallbacks; private ScrollState mScrollState; private boolean mFirstScroll; private boolean mDragging; private boolean mIntercepted; private MotionEvent mPrevMoveEvent; private ViewGroup mTouchInterceptionViewGroup; private OnScrollListener mOriginalScrollListener; private OnScrollListener mScrollListener = new OnScrollListener() { @Override public void onScrollStateChanged(AbsListView view, int scrollState) { if (mOriginalScrollListener != null) { mOriginalScrollListener.onScrollStateChanged(view, scrollState); } } @Override public void onScroll(AbsListView view, int firstVisibleItem, int visibleItemCount, int totalItemCount) { if (mOriginalScrollListener != null) { mOriginalScrollListener.onScroll(view, firstVisibleItem, visibleItemCount, totalItemCount); } // AbsListView#invokeOnItemScrollListener calls onScrollChanged(0, 0, 0, 0) // on Android 4.0+, but Android 2.3 is not. (Android 3.0 is unknown) // So call it with onScrollListener. onScrollChanged(); } }; public ObservableListView(Context context) { super(context); init(); } public ObservableListView(Context context, AttributeSet attrs) { super(context, attrs); init(); } public ObservableListView(Context context, AttributeSet attrs, int defStyle) { super(context, attrs, defStyle); init(); } public boolean isDragging() { return mDragging; } public void clearParams() { mPrevFirstVisiblePosition = 0; mPrevFirstVisibleChildHeight = -1; mPrevScrolledChildrenHeight = 0; mPrevScrollY = 0; mScrollY = 0; mChildrenHeights = new SparseIntArray(); } @Override public void onRestoreInstanceState(Parcelable state) { SavedState ss = (SavedState) state; mPrevFirstVisiblePosition = ss.prevFirstVisiblePosition; mPrevFirstVisibleChildHeight = ss.prevFirstVisibleChildHeight; mPrevScrolledChildrenHeight = ss.prevScrolledChildrenHeight; mPrevScrollY = ss.prevScrollY; mScrollY = ss.scrollY; mChildrenHeights = ss.childrenHeights; super.onRestoreInstanceState(ss.getSuperState()); } @Override public Parcelable onSaveInstanceState() { Parcelable superState = super.onSaveInstanceState(); SavedState ss = new SavedState(superState); ss.prevFirstVisiblePosition = mPrevFirstVisiblePosition; ss.prevFirstVisibleChildHeight = mPrevFirstVisibleChildHeight; ss.prevScrolledChildrenHeight = mPrevScrolledChildrenHeight; ss.prevScrollY = mPrevScrollY; ss.scrollY = mScrollY; ss.childrenHeights = mChildrenHeights; return ss; } @Override public boolean onInterceptTouchEvent(MotionEvent ev) { if (mCallbacks != null) { 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; mCallbacks.onDownMotionEvent(); break; } } return super.onInterceptTouchEvent(ev); } @Override public boolean onTouchEvent(MotionEvent ev) { if (mCallbacks != null) { switch (ev.getActionMasked()) { case MotionEvent.ACTION_UP: case MotionEvent.ACTION_CANCEL: mIntercepted = false; mDragging = false; mCallbacks.onUpOrCancelMotionEvent(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 setOnScrollListener(OnScrollListener l) { // Don't set l to super.setOnScrollListener(). // l receives all events through mScrollListener. mOriginalScrollListener = l; } @Override public void setScrollViewCallbacks(ObservableScrollViewCallbacks listener) { mCallbacks = listener; } @Override public void setTouchInterceptionViewGroup(ViewGroup viewGroup) { mTouchInterceptionViewGroup = viewGroup; } @Override public void scrollVerticallyTo(int y) { View firstVisibleChild = getChildAt(0); if (firstVisibleChild != null) { int baseHeight = firstVisibleChild.getHeight(); int position = y / baseHeight; setSelection(position); } } @Override public int getCurrentScrollY() { return mScrollY; } private void init() { mChildrenHeights = new SparseIntArray(); super.setOnScrollListener(mScrollListener); } private void onScrollChanged() { if (mCallbacks != null) { if (getChildCount() > 0) { int firstVisiblePosition = getFirstVisiblePosition(); for (int i = getFirstVisiblePosition(), j = 0; i <= getLastVisiblePosition(); i++, j++) { if (mChildrenHeights.indexOfKey(i) < 0 || getChildAt(j).getHeight() != mChildrenHeights.get(i)) { mChildrenHeights.put(i, getChildAt(j).getHeight()); } } View firstVisibleChild = getChildAt(0); if (firstVisibleChild != null) { if (mPrevFirstVisiblePosition < firstVisiblePosition) { // scroll down int skippedChildrenHeight = 0; if (firstVisiblePosition - mPrevFirstVisiblePosition != 1) { for (int i = firstVisiblePosition - 1; i > mPrevFirstVisiblePosition; i--) { if (0 < mChildrenHeights.indexOfKey(i)) { skippedChildrenHeight += mChildrenHeights.get(i); } else { // Approximate each item's height to the first visible child. // It may be incorrect, but without this, scrollY will be broken // when scrolling from the bottom. skippedChildrenHeight += firstVisibleChild.getHeight(); } } } mPrevScrolledChildrenHeight += mPrevFirstVisibleChildHeight + skippedChildrenHeight; mPrevFirstVisibleChildHeight = firstVisibleChild.getHeight(); } else if (firstVisiblePosition < mPrevFirstVisiblePosition) { // scroll up int skippedChildrenHeight = 0; if (mPrevFirstVisiblePosition - firstVisiblePosition != 1) { for (int i = mPrevFirstVisiblePosition - 1; i > firstVisiblePosition; i--) { if (0 < mChildrenHeights.indexOfKey(i)) { skippedChildrenHeight += mChildrenHeights.get(i); } else { // Approximate each item's height to the first visible child. // It may be incorrect, but without this, scrollY will be broken // when scrolling from the bottom. skippedChildrenHeight += firstVisibleChild.getHeight(); } } } mPrevScrolledChildrenHeight -= firstVisibleChild.getHeight() + skippedChildrenHeight; mPrevFirstVisibleChildHeight = firstVisibleChild.getHeight(); } else if (firstVisiblePosition == 0) { mPrevFirstVisibleChildHeight = firstVisibleChild.getHeight(); } if (mPrevFirstVisibleChildHeight < 0) { mPrevFirstVisibleChildHeight = 0; } mScrollY = mPrevScrolledChildrenHeight - firstVisibleChild.getTop(); mPrevFirstVisiblePosition = firstVisiblePosition; /* mCallbacks.onScrollChanged(mScrollY, mFirstScroll, mDragging); if (mFirstScroll) { mFirstScroll = false; } if (mPrevScrollY < mScrollY) { mScrollState = ScrollState.UP; } else if (mScrollY < mPrevScrollY) { mScrollState = ScrollState.DOWN; } else { mScrollState = ScrollState.STOP; } mPrevScrollY = mScrollY; */ } } } } @Override protected void onScrollChanged(int l, int t, int oldl, int oldt) { super.onScrollChanged(l, t, oldl, oldt); if (mCallbacks != null) { //mScrollY = t; mCallbacks.onScrollChanged(mScrollY + 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; } } static class SavedState extends BaseSavedState { int prevFirstVisiblePosition; int prevFirstVisibleChildHeight = -1; int prevScrolledChildrenHeight; int prevScrollY; int scrollY; SparseIntArray childrenHeights; /** * Called by onSaveInstanceState. */ SavedState(Parcelable superState) { super(superState); } /** * Called by CREATOR. */ private SavedState(Parcel in) { super(in); prevFirstVisiblePosition = in.readInt(); prevFirstVisibleChildHeight = in.readInt(); prevScrolledChildrenHeight = in.readInt(); prevScrollY = in.readInt(); scrollY = in.readInt(); childrenHeights = new SparseIntArray(); final int numOfChildren = in.readInt(); if (0 < numOfChildren) { for (int i = 0; i < numOfChildren; i++) { final int key = in.readInt(); final int value = in.readInt(); childrenHeights.put(key, value); } } } @Override public void writeToParcel(Parcel out, int flags) { super.writeToParcel(out, flags); out.writeInt(prevFirstVisiblePosition); out.writeInt(prevFirstVisibleChildHeight); out.writeInt(prevScrolledChildrenHeight); out.writeInt(prevScrollY); out.writeInt(scrollY); final int numOfChildren = childrenHeights == null ? 0 : childrenHeights.size(); out.writeInt(numOfChildren); if (0 < numOfChildren) { for (int i = 0; i < numOfChildren; i++) { out.writeInt(childrenHeights.keyAt(i)); out.writeInt(childrenHeights.valueAt(i)); } } } public static final Parcelable.Creator<SavedState> CREATOR = new Parcelable.Creator<SavedState>() { @Override public SavedState createFromParcel(Parcel in) { return new SavedState(in); } @Override public SavedState[] newArray(int size) { return new SavedState[size]; } }; } }