diff --git a/library/src/main/java/rx/android/events/OnListViewScrollEvent.java b/library/src/main/java/rx/android/events/OnListViewScrollEvent.java new file mode 100644 index 00000000..e0193224 --- /dev/null +++ b/library/src/main/java/rx/android/events/OnListViewScrollEvent.java @@ -0,0 +1,85 @@ +/** + * 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 rx.android.events; + +import android.widget.AbsListView; + +public class OnListViewScrollEvent { + public final AbsListView listView; + public final int scrollState; + public final int firstVisibleItem; + public final int visibleItemCount; + public final int totalItemCount; + + public OnListViewScrollEvent( + AbsListView listView, int scrollState, int firstVisibleItem, int visibleItemCount, int totalItemCount) { + this.listView = listView; + this.scrollState = scrollState; + this.firstVisibleItem = firstVisibleItem; + this.visibleItemCount = visibleItemCount; + this.totalItemCount = totalItemCount; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + + OnListViewScrollEvent that = (OnListViewScrollEvent) o; + + if (firstVisibleItem != that.firstVisibleItem) { + return false; + } + if (scrollState != that.scrollState) { + return false; + } + if (totalItemCount != that.totalItemCount) { + return false; + } + if (visibleItemCount != that.visibleItemCount) { + return false; + } + if (!listView.equals(that.listView)) { + return false; + } + + return true; + } + + @Override + public int hashCode() { + int result = listView.hashCode(); + result = 31 * result + scrollState; + result = 31 * result + firstVisibleItem; + result = 31 * result + visibleItemCount; + result = 31 * result + totalItemCount; + return result; + } + + @Override + public String toString() { + return "OnListViewScrollEvent{" + + "listView=" + listView + + ", scrollState=" + scrollState + + ", firstVisibleItem=" + firstVisibleItem + + ", visibleItemCount=" + visibleItemCount + + ", totalItemCount=" + totalItemCount + + '}'; + } +} diff --git a/library/src/main/java/rx/android/observables/ViewObservable.java b/library/src/main/java/rx/android/observables/ViewObservable.java index 4a8af72d..69f30f18 100644 --- a/library/src/main/java/rx/android/observables/ViewObservable.java +++ b/library/src/main/java/rx/android/observables/ViewObservable.java @@ -14,6 +14,7 @@ package rx.android.observables; import android.view.View; +import android.widget.AbsListView; import android.widget.AdapterView; import android.widget.CompoundButton; import android.widget.TextView; @@ -22,9 +23,11 @@ import rx.android.events.OnCheckedChangeEvent; import rx.android.events.OnClickEvent; import rx.android.events.OnItemClickEvent; +import rx.android.events.OnListViewScrollEvent; import rx.android.events.OnTextChangeEvent; import rx.android.operators.OperatorAdapterViewOnItemClick; import rx.android.operators.OperatorCompoundButtonInput; +import rx.android.operators.OnSubscribeListViewScroll; import rx.android.operators.OperatorTextViewInput; import rx.android.operators.OperatorViewClick; @@ -58,4 +61,13 @@ public static Observable itemClicks(final AdapterView adapt return Observable.create(new OperatorAdapterViewOnItemClick(adapterView)); } + /** + * Returns an observable that emits all the scroll events from the provided ListView. + * Note that this will replace any listeners previously set through + * {@link android.widget.AbsListView#setOnScrollListener(android.widget.AbsListView.OnScrollListener)} unless those + * were set by this method or {@link rx.android.operators.OnSubscribeListViewScroll}. + */ + public static Observable listScrollEvents(final AbsListView listView) { + return Observable.create(new OnSubscribeListViewScroll(listView)); + } } diff --git a/library/src/main/java/rx/android/operators/OnSubscribeListViewScroll.java b/library/src/main/java/rx/android/operators/OnSubscribeListViewScroll.java new file mode 100644 index 00000000..a7b97c9d --- /dev/null +++ b/library/src/main/java/rx/android/operators/OnSubscribeListViewScroll.java @@ -0,0 +1,115 @@ +/** + * 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 rx.android.operators; + +import android.widget.AbsListView; +import android.widget.AdapterView; +import rx.Observable; +import rx.Subscriber; +import rx.android.events.OnListViewScrollEvent; +import rx.android.observables.Assertions; +import rx.android.subscriptions.AndroidSubscriptions; +import rx.functions.Action0; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.WeakHashMap; + +public class OnSubscribeListViewScroll implements Observable.OnSubscribe { + + private final AbsListView listView; + + public OnSubscribeListViewScroll(AbsListView listView) { + this.listView = listView; + } + + @Override + public void call(final Subscriber observer) { + Assertions.assertUiThread(); + + final CompositeOnScrollListener composite = CachedListeners.getFromViewOrCreate(listView); + final AbsListView.OnScrollListener listener = new AbsListView.OnScrollListener() { + int currentScrollState = SCROLL_STATE_IDLE; + + @Override + public void onScrollStateChanged(AbsListView view, int scrollState) { + this.currentScrollState = scrollState; + } + + @Override + public void onScroll(AbsListView view, int firstVisibleItem, int visibleItemCount, int totalItemCount) { + OnListViewScrollEvent event = new OnListViewScrollEvent(view, this.currentScrollState, firstVisibleItem, + visibleItemCount, totalItemCount); + observer.onNext(event); + } + }; + + composite.addOnScrollListener(listener); + observer.add(AndroidSubscriptions.unsubscribeInUiThread(new Action0() { + @Override + public void call() { + composite.removeOnScrollListener(listener); + } + })); + } + + private static class CompositeOnScrollListener implements AbsListView.OnScrollListener { + + private final List listeners = new ArrayList(); + + public boolean addOnScrollListener(final AbsListView.OnScrollListener listener) { + return listeners.add(listener); + } + + public boolean removeOnScrollListener(final AbsListView.OnScrollListener listener) { + return listeners.remove(listener); + } + + @Override + public void onScrollStateChanged(AbsListView view, int scrollState) { + for (AbsListView.OnScrollListener listener : listeners) { + listener.onScrollStateChanged(view, scrollState); + } + } + + @Override + public void onScroll(AbsListView view, int firstVisibleItem, int visibleItemCount, int totalItemCount) { + for (AbsListView.OnScrollListener listener : listeners) { + listener.onScroll(view, firstVisibleItem, visibleItemCount, totalItemCount); + } + } + } + + private static class CachedListeners { + + private static final Map, CompositeOnScrollListener> sCachedListeners = + new WeakHashMap, CompositeOnScrollListener>(); + + public static CompositeOnScrollListener getFromViewOrCreate(final AbsListView view) { + final CompositeOnScrollListener cached = sCachedListeners.get(view); + if (cached != null) { + return cached; + } + + final CompositeOnScrollListener listener = new CompositeOnScrollListener(); + + sCachedListeners.put(view, listener); + view.setOnScrollListener(listener); + + return listener; + } + } +} diff --git a/library/src/test/java/rx/android/operators/OnSubscribeListViewScrollTest.java b/library/src/test/java/rx/android/operators/OnSubscribeListViewScrollTest.java new file mode 100644 index 00000000..d174299f --- /dev/null +++ b/library/src/test/java/rx/android/operators/OnSubscribeListViewScrollTest.java @@ -0,0 +1,113 @@ +package rx.android.operators; + +import android.widget.AbsListView; +import android.widget.ListView; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Captor; +import org.mockito.InOrder; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; +import org.robolectric.RobolectricTestRunner; +import rx.Observer; +import rx.Subscription; +import rx.android.events.OnListViewScrollEvent; +import rx.android.observables.ViewObservable; +import rx.observers.TestObserver; + +import java.util.ArrayList; +import java.util.List; + +import static org.mockito.Mockito.*; + +@RunWith(RobolectricTestRunner.class) +public class OnSubscribeListViewScrollTest { + + @Mock + private ListView listView; + + @Captor + private ArgumentCaptor captor; + + private List events; + + @Before + public void setup() { + MockitoAnnotations.initMocks(this); + + events = new ArrayList(); + for (int i = 0; i < 10; i++) { + events.add( + new OnListViewScrollEvent(listView, AbsListView.OnScrollListener.SCROLL_STATE_TOUCH_SCROLL, i, 2, 10)); + } + } + + @Test + @SuppressWarnings("unchecked") + public void testEventsEmitted() { + Observer observer = mock(Observer.class); + Subscription subscription = + ViewObservable.listScrollEvents(listView).subscribe(new TestObserver(observer)); + + verify(observer, never()).onNext(any(OnListViewScrollEvent.class)); + + verify(listView, times(1)).setOnScrollListener(captor.capture()); + captor.getValue().onScrollStateChanged(listView, AbsListView.OnScrollListener.SCROLL_STATE_TOUCH_SCROLL); + + InOrder inOrder = inOrder(observer); + + verifyObserversGetEvents(inOrder, observer); + + subscription.unsubscribe(); + + verifyNoInteractionsOnEventEmit(inOrder); + } + + @SuppressWarnings("unchecked") + public void testMultipleObservablesOnSameViewGetCorrectEvents() { + Observer observerA = mock(Observer.class); + Subscription subscriptionA = ViewObservable.listScrollEvents(listView) + .subscribe(new TestObserver(observerA)); + + Observer observerB = mock(Observer.class); + Subscription subscriptionB = ViewObservable.listScrollEvents(listView) + .subscribe(new TestObserver(observerB)); + + verify(listView, times(2)).setOnScrollListener(captor.capture()); + + InOrder inOrder = inOrder(observerA, observerB); + verifyObserversGetEvents(inOrder, observerA, observerB); + + subscriptionA.unsubscribe(); + + verifyObserversGetEvents(inOrder, observerB); + + subscriptionB.unsubscribe(); + verifyNoInteractionsOnEventEmit(inOrder); + } + + private void verifyNoInteractionsOnEventEmit(InOrder inOrder) { + OnListViewScrollEvent lastEvent = events.get(0); + captor.getValue().onScroll(listView, + lastEvent.firstVisibleItem, + lastEvent.visibleItemCount, + lastEvent.totalItemCount); + inOrder.verifyNoMoreInteractions(); + } + + private void verifyObserversGetEvents(InOrder inOrder, Observer... observers) { + for (OnListViewScrollEvent event : events) { + captor.getValue().onScroll(event.listView, + event.firstVisibleItem, + event.visibleItemCount, + event.totalItemCount); + for (Observer observer : observers) { + inOrder.verify(observer, times(1)).onNext(event); + } + } + + inOrder.verifyNoMoreInteractions(); + } +} diff --git a/sample-app/src/main/java/rx/android/samples/ListFragmentActivity.java b/sample-app/src/main/java/rx/android/samples/ListFragmentActivity.java index 0a3d549d..3682ae5f 100644 --- a/sample-app/src/main/java/rx/android/samples/ListFragmentActivity.java +++ b/sample-app/src/main/java/rx/android/samples/ListFragmentActivity.java @@ -1,12 +1,20 @@ package rx.android.samples; import android.app.Activity; -import android.app.ListFragment; +import android.app.Fragment; import android.os.Bundle; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; import android.widget.ArrayAdapter; - +import android.widget.ListView; +import android.widget.ProgressBar; import rx.Observable; import rx.Subscriber; +import rx.android.events.OnListViewScrollEvent; +import rx.android.observables.AndroidObservable; +import rx.android.observables.ViewObservable; +import rx.functions.Action1; import static rx.android.schedulers.AndroidSchedulers.mainThread; @@ -30,7 +38,7 @@ protected void onCreate(Bundle savedInstanceState) { } @SuppressWarnings("ConstantConditions") - public static class RetainedListFragment extends ListFragment { + public static class RetainedListFragment extends Fragment { private ArrayAdapter adapter; @@ -39,18 +47,38 @@ public RetainedListFragment() { } @Override - public void onCreate(Bundle savedInstanceState) { - super.onCreate(savedInstanceState); + public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) { + View view = inflater.inflate(R.layout.list_fragment, container, false); adapter = new ArrayAdapter(getActivity(), android.R.layout.simple_list_item_1); - setListAdapter(adapter); - SampleObservables.numberStrings(1, 20, 250) - .observeOn(mainThread()) - .lift(new BindAdapter()) - .subscribe(); + ListView listView = (ListView) view.findViewById(android.R.id.list); + listView.setAdapter(adapter); + + AndroidObservable.bindFragment(this, SampleObservables.numberStrings(1, 500, 100)) + .observeOn(mainThread()) + .lift(new BindAdapter()) + .subscribe(); + + final ProgressBar progressBar = (ProgressBar) view.findViewById(android.R.id.progress); + AndroidObservable.bindFragment(this, ViewObservable.listScrollEvents(listView)) + .subscribe(new Action1() { + @Override + public void call(OnListViewScrollEvent event) { + if (event.totalItemCount == 0) { + return; + } + + int progress = + (int) ((100.0 * (event.firstVisibleItem + event.visibleItemCount)) / event.totalItemCount); + progressBar.setProgress(progress); + } + }); + + return view; } private final class BindAdapter implements Observable.Operator { + @Override public Subscriber call(Subscriber subscriber) { return new Subscriber() { diff --git a/sample-app/src/main/res/layout/list_fragment.xml b/sample-app/src/main/res/layout/list_fragment.xml index be671b87..e1dd09de 100644 --- a/sample-app/src/main/res/layout/list_fragment.xml +++ b/sample-app/src/main/res/layout/list_fragment.xml @@ -1,18 +1,18 @@ - - + android:layout_height="48dp" + android:indeterminate="false" /> + android:layout_height="match_parent" + android:layout_marginTop="48dp" /> - +