diff --git a/src/main/java/rx/android/events/OnItemClickEvent.java b/src/main/java/rx/android/events/OnItemClickEvent.java new file mode 100644 index 00000000..48de57a9 --- /dev/null +++ b/src/main/java/rx/android/events/OnItemClickEvent.java @@ -0,0 +1,19 @@ +package rx.android.events; + +import android.view.View; +import android.widget.Adapter; +import android.widget.AdapterView; + +public class OnItemClickEvent { + public final AdapterView parent; + public final View view; + public final int position; + public final long id; + + public OnItemClickEvent(AdapterView parent, View view, int position, long id) { + this.parent = parent; + this.view = view; + this.position = position; + this.id = id; + } +} diff --git a/src/main/java/rx/android/observables/AndroidObservable.java b/src/main/java/rx/android/observables/AndroidObservable.java index 609dbf20..97c31e85 100644 --- a/src/main/java/rx/android/observables/AndroidObservable.java +++ b/src/main/java/rx/android/observables/AndroidObservable.java @@ -15,23 +15,23 @@ */ package rx.android.observables; -import static rx.android.schedulers.AndroidSchedulers.mainThread; - -import android.content.SharedPreferences; -import rx.Observable; -import rx.functions.Func1; -import rx.android.operators.OperatorBroadcastRegister; -import rx.android.operators.OperatorConditionalBinding; -import rx.android.operators.OperatorLocalBroadcastRegister; - import android.app.Activity; import android.app.Fragment; import android.content.Context; import android.content.Intent; import android.content.IntentFilter; +import android.content.SharedPreferences; import android.os.Build; import android.os.Handler; -import rx.operators.OperatorSharedPreferenceChange; + +import rx.Observable; +import rx.android.operators.OperatorBroadcastRegister; +import rx.android.operators.OperatorConditionalBinding; +import rx.android.operators.OperatorLocalBroadcastRegister; +import rx.android.operators.OperatorSharedPreferenceChange; +import rx.functions.Func1; + +import static rx.android.schedulers.AndroidSchedulers.mainThread; public final class AndroidObservable { diff --git a/src/main/java/rx/android/observables/ViewObservable.java b/src/main/java/rx/android/observables/ViewObservable.java index 974c6bbc..4a05af05 100644 --- a/src/main/java/rx/android/observables/ViewObservable.java +++ b/src/main/java/rx/android/observables/ViewObservable.java @@ -15,18 +15,21 @@ */ package rx.android.observables; +import android.view.View; +import android.widget.AdapterView; +import android.widget.CompoundButton; +import android.widget.TextView; + import rx.Observable; import rx.android.events.OnCheckedChangeEvent; import rx.android.events.OnClickEvent; +import rx.android.events.OnItemClickEvent; import rx.android.events.OnTextChangeEvent; +import rx.android.operators.OperatorAdapterViewOnItemClick; import rx.android.operators.OperatorCompoundButtonInput; import rx.android.operators.OperatorTextViewInput; import rx.android.operators.OperatorViewClick; -import android.view.View; -import android.widget.CompoundButton; -import android.widget.TextView; - public class ViewObservable { public static Observable clicks(final View view) { @@ -53,4 +56,8 @@ public static Observable input(final CompoundButton button return Observable.create(new OperatorCompoundButtonInput(button, emitInitialValue)); } + public static Observable itemClicks(final AdapterView adapterView) { + return Observable.create(new OperatorAdapterViewOnItemClick(adapterView)); + } + } diff --git a/src/main/java/rx/android/operators/OperatorAdapterViewOnItemClick.java b/src/main/java/rx/android/operators/OperatorAdapterViewOnItemClick.java new file mode 100644 index 00000000..a35bed24 --- /dev/null +++ b/src/main/java/rx/android/operators/OperatorAdapterViewOnItemClick.java @@ -0,0 +1,103 @@ +/** + * Copyright 2014 Netflix, Inc. + * + * 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.view.View; +import android.widget.AbsListView; +import android.widget.AdapterView; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.WeakHashMap; + +import rx.Observable; +import rx.Subscriber; +import rx.Subscription; +import rx.android.events.OnItemClickEvent; +import rx.android.observables.Assertions; +import rx.android.subscriptions.AndroidSubscriptions; +import rx.functions.Action0; + +public class OperatorAdapterViewOnItemClick implements Observable.OnSubscribe { + + private final AdapterView adapterView; + + public OperatorAdapterViewOnItemClick(final AdapterView adapterView) { + this.adapterView = adapterView; + } + + @Override + public void call(final Subscriber observer) { + Assertions.assertUiThread(); + final CompositeOnClickListener composite = CachedListeners.getFromViewOrCreate(adapterView); + + final AbsListView.OnItemClickListener listener = new AdapterView.OnItemClickListener() { + @Override + public void onItemClick(AdapterView parent, View view, int position, long id) { + observer.onNext(new OnItemClickEvent(parent, view, position, id)); + } + }; + + final Subscription subscription = AndroidSubscriptions.unsubscribeInUiThread(new Action0() { + @Override + public void call() { + composite.removeOnClickListener(listener); + } + }); + + composite.addOnClickListener(listener); + observer.add(subscription); + } + + private static class CompositeOnClickListener implements AbsListView.OnItemClickListener { + private final List listeners = new ArrayList(); + + public boolean addOnClickListener(final AbsListView.OnItemClickListener listener) { + return listeners.add(listener); + } + + public boolean removeOnClickListener(final AbsListView.OnItemClickListener listener) { + return listeners.remove(listener); + } + + @Override + public void onItemClick(AdapterView parent, View view, int position, long id) { + for (AdapterView.OnItemClickListener listener : listeners) { + listener.onItemClick(parent, view, position, id); + } + } + } + + private static class CachedListeners { + private static final Map, CompositeOnClickListener> sCachedListeners = new WeakHashMap, CompositeOnClickListener>(); + + public static CompositeOnClickListener getFromViewOrCreate(final AdapterView view) { + final CompositeOnClickListener cached = sCachedListeners.get(view); + + if (cached != null) { + return cached; + } + + final CompositeOnClickListener listener = new CompositeOnClickListener(); + + sCachedListeners.put(view, listener); + view.setOnItemClickListener(listener); + + return listener; + } + } +} diff --git a/src/main/java/rx/operators/OperatorSharedPreferenceChange.java b/src/main/java/rx/android/operators/OperatorSharedPreferenceChange.java similarity index 98% rename from src/main/java/rx/operators/OperatorSharedPreferenceChange.java rename to src/main/java/rx/android/operators/OperatorSharedPreferenceChange.java index e8c09429..ce5ec11f 100644 --- a/src/main/java/rx/operators/OperatorSharedPreferenceChange.java +++ b/src/main/java/rx/android/operators/OperatorSharedPreferenceChange.java @@ -13,7 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package rx.operators; +package rx.android.operators; import android.content.SharedPreferences; import rx.Observable; diff --git a/src/test/java/rx/android/operators/OperatorAdapterViewOnItemClickTest.java b/src/test/java/rx/android/operators/OperatorAdapterViewOnItemClickTest.java new file mode 100644 index 00000000..1f7707ff --- /dev/null +++ b/src/test/java/rx/android/operators/OperatorAdapterViewOnItemClickTest.java @@ -0,0 +1,415 @@ +/** + * Copyright 2014 Netflix, Inc. + * + * 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.app.Activity; +import android.view.View; +import android.widget.Adapter; +import android.widget.AdapterView; +import android.widget.ArrayAdapter; +import android.widget.BaseAdapter; +import android.widget.GridView; +import android.widget.ListView; + +import junit.framework.Assert; + +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.InOrder; +import org.robolectric.Robolectric; +import org.robolectric.RobolectricTestRunner; + +import java.util.ArrayList; +import java.util.List; + +import rx.Observable; +import rx.Observer; +import rx.Subscription; +import rx.android.events.OnItemClickEvent; +import rx.android.observables.ViewObservable; +import rx.observers.TestObserver; + +import static org.mockito.Matchers.any; +import static org.mockito.Mockito.inOrder; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; + +@RunWith(RobolectricTestRunner.class) +public class OperatorAdapterViewOnItemClickTest { + + private static ListView createListView(List values) { + final Activity activity = Robolectric.buildActivity(Activity.class).create().get(); + final ListView listView = new ListView(activity); + BaseAdapter adapter = new ArrayAdapter(activity, android.R.layout.simple_list_item_1, values); + listView.setAdapter(adapter); + return listView; + } + + private static GridView createGridView(List values) { + final Activity activity = Robolectric.buildActivity(Activity.class).create().get(); + final GridView gridView = new GridView(activity); + BaseAdapter adapter = new ArrayAdapter(activity, android.R.layout.simple_list_item_1, values); + gridView.setAdapter(adapter); + return gridView; + } + + private static List createValues(int count) { + final List values = new ArrayList(count); + for (int i = 0; i < count; i++) { + values.add(String.valueOf(i)); + } + return values; + } + + @Test + public void testListViewNeverEmitEventBeforeSubscribed() { + final ListView listView = createListView(createValues(10)); + performTestAdapterViewNeverEmitEventBeforeSubscribed(listView); + } + + @Test + public void testGridViewNeverEmitEventBeforeSubscribed() { + final GridView gridView = createGridView(createValues(10)); + performTestAdapterViewNeverEmitEventBeforeSubscribed(gridView); + } + + @Test + public void testListViewClickAllViewsEmitAllEvents() { + final ListView listView = createListView(createValues(10)); + performTestAdapterViewClickAllViewsEmitAllEvents(listView); + } + + @Test + public void testGridViewClickAllViewsEmitAllEvents() { + final GridView gridView = createGridView(createValues(10)); + performTestAdapterViewClickAllViewsEmitAllEvents(gridView); + } + + @Test + public void testListViewNeverEmitEventAfterUnsubscribed() { + final ListView listView = createListView(createValues(10)); + performTestAdapterViewNeverEmitEventAfterUnsubscribed(listView); + } + + @Test + public void testGridViewNeverEmitEventAfterUnsubscribed() { + final GridView gridView = createGridView(createValues(10)); + performTestAdapterViewClickAllViewsEmitAllEvents(gridView); + } + + @Test + public void testListViewNeverEmitAnyThrowableAfterUnsubscribed() { + final ListView listView = createListView(createValues(10)); + performTestAdapterViewNeverEmitAnyThrowableAfterUnsubscribed(listView); + } + + @Test + public void testGridViewNeverEmitAnyThrowableAfterUnsubscribed() { + final GridView gridView = createGridView(createValues(10)); + performTestAdapterViewNeverEmitAnyThrowableAfterUnsubscribed(gridView); + } + + @Test + public void testListViewNeverEmitOnCompletedAfterUnsubscribed() { + final ListView listView = createListView(createValues(10)); + performTestAdapterViewNeverEmitOnCompletedAfterUnsubscribed(listView); + } + + @Test + public void testGridViewNeverEmitOnCompletedAfterUnsubscribed() { + final GridView gridView = createGridView(createValues(10)); + performTestAdapterViewNeverEmitOnCompletedAfterUnsubscribed(gridView); + } + + @Test + public void testMultipleSubscriptionsListViewNeverEmitEventBeforeSubscribed() { + final ListView listView = createListView(createValues(10)); + performTestAdapterViewMultipleSubscriptionsNeverEmitEventBeforeSubscribed(listView); + } + + @Test + public void testMultipleSubscriptionsGridViewNeverEmitEventBeforeSubscribed() { + final GridView gridView = createGridView(createValues(10)); + performTestAdapterViewMultipleSubscriptionsNeverEmitEventBeforeSubscribed(gridView); + } + + @Test + public void testMultipleSubscriptionsListViewClickAllViewsEmitAllEvents() { + final ListView listView = createListView(createValues(10)); + performTestAdapterViewMultipleSubscriptionsClickAllViewsEmitAllEvents(listView); + } + + @Test + public void testMultipleSubscriptionsGridViewClickAllViewsEmitAllEvents() { + final GridView gridView = createGridView(createValues(10)); + performTestAdapterViewMultipleSubscriptionsClickAllViewsEmitAllEvents(gridView); + } + + @Test + public void testMultipleSubscriptionsListViewClickAllViewsEmitAllEventsForOneSubscriber() { + final ListView listView = createListView(createValues(10)); + performTestAdapterViewMultipleSubscriptionsClickAllViewsEmitAllEventsForOneSubscriber(listView); + } + + @Test + public void testMultipleSubscriptionsGridViewClickAllViewsEmitAllEventsForOneSubscriber() { + final GridView gridView = createGridView(createValues(10)); + performTestAdapterViewMultipleSubscriptionsClickAllViewsEmitAllEventsForOneSubscriber(gridView); + } + + @Test + public void testMultipleSubscriptionsListViewNeverEmitEventAfterUnsubscribed() { + final ListView listView = createListView(createValues(10)); + performTestAdapterViewMultipleSubscriptionsNeverEmitEventAfterUnsubscribed(listView); + } + + @Test + public void testMultipleSubscriptionsGridViewNeverEmitEventAfrerUnsubscribed() { + final GridView gridView = createGridView(createValues(10)); + performTestAdapterViewMultipleSubscriptionsNeverEmitEventAfterUnsubscribed(gridView); + } + + @Test + public void testMultipleSubscriptionsListViewNeverEmitAnyThrowableAfterUnsubscribed() { + final ListView listView = createListView(createValues(10)); + performTestAdapterViewMultipleSubscriptionsNeverEmitAnyThrowableAfterUnsubscribed(listView); + } + + @Test + public void testMultipleSubscriptionsGridViewNeverEmitAnyThrowableAfterUnsubscribed() { + final GridView gridView = createGridView(createValues(10)); + performTestAdapterViewMultipleSubscriptionsNeverEmitAnyThrowableAfterUnsubscribed(gridView); + } + + @Test + public void testMultipleSubscriptionsListViewNeverEmitOnCompletedAfterUnsubscribed() { + final ListView listView = createListView(createValues(10)); + performTestAdapterViewMultipleSubscriptionsNeverEmitOnCompletedAfterUnsubscribed(listView); + } + + @Test + public void testMultipleSubscriptionsGridViewNeverEmitOnCompletedAfterUnsubscribed() { + final GridView gridView = createGridView(createValues(10)); + performTestAdapterViewMultipleSubscriptionsNeverEmitOnCompletedAfterUnsubscribed(gridView); + } + + @SuppressWarnings("unchecked") + private void performTestAdapterViewNeverEmitEventBeforeSubscribed(AdapterView adapterView) { + Adapter adapter = adapterView.getAdapter(); + Assert.assertNotNull(adapter); + final Observable observable = ViewObservable.itemClicks(adapterView); + final Observer observer = mock(Observer.class); + final Subscription subscription = observable.subscribe(new TestObserver(observer)); + + final InOrder inOrder = inOrder(observer); + inOrder.verify(observer, never()).onNext(any(OnItemClickEvent.class)); + + subscription.unsubscribe(); + } + + @SuppressWarnings("unchecked") + private void performTestAdapterViewClickAllViewsEmitAllEvents(AdapterView adapterView) { + Adapter adapter = adapterView.getAdapter(); + Assert.assertNotNull(adapter); + final Observable observable = ViewObservable.itemClicks(adapterView); + final Observer observer = mock(Observer.class); + final Subscription subscription = observable.subscribe(new TestObserver(observer)); + + final InOrder inOrder = inOrder(observer); + + for (int i = 0; i < adapter.getCount(); i++) { + adapterView.performItemClick(any(View.class), i, i); + inOrder.verify(observer, times(1)).onNext(new OnItemClickEvent(adapterView, any(View.class), i, i)); + } + + subscription.unsubscribe(); + } + + @SuppressWarnings("unchecked") + private void performTestAdapterViewNeverEmitEventAfterUnsubscribed(AdapterView adapterView) { + Adapter adapter = adapterView.getAdapter(); + Assert.assertNotNull(adapter); + final Observable observable = ViewObservable.itemClicks(adapterView); + final Observer observer = mock(Observer.class); + final Subscription subscription = observable.subscribe(new TestObserver(observer)); + + final InOrder inOrder = inOrder(observer); + + subscription.unsubscribe(); + + inOrder.verify(observer, never()).onNext(any(OnItemClickEvent.class)); + } + + @SuppressWarnings("unchecked") + private void performTestAdapterViewNeverEmitAnyThrowableAfterUnsubscribed(AdapterView adapterView) { + Adapter adapter = adapterView.getAdapter(); + Assert.assertNotNull(adapter); + final Observable observable = ViewObservable.itemClicks(adapterView); + final Observer observer = mock(Observer.class); + final Subscription subscription = observable.subscribe(new TestObserver(observer)); + + final InOrder inOrder = inOrder(observer); + subscription.unsubscribe(); + + inOrder.verify(observer, never()).onError(any(Throwable.class)); + } + + @SuppressWarnings("unchecked") + private void performTestAdapterViewNeverEmitOnCompletedAfterUnsubscribed(AdapterView adapterView) { + Adapter adapter = adapterView.getAdapter(); + Assert.assertNotNull(adapter); + final Observable observable = ViewObservable.itemClicks(adapterView); + final Observer observer = mock(Observer.class); + final Subscription subscription = observable.subscribe(new TestObserver(observer)); + + final InOrder inOrder = inOrder(observer); + subscription.unsubscribe(); + + inOrder.verify(observer, never()).onCompleted(); + } + + @SuppressWarnings("unchecked") + private void performTestAdapterViewMultipleSubscriptionsNeverEmitEventBeforeSubscribed(AdapterView adapterView) { + Adapter adapter = adapterView.getAdapter(); + Assert.assertNotNull(adapter); + final Observable observable = ViewObservable.itemClicks(adapterView); + final Observer observer1 = mock(Observer.class); + final Observer observer2 = mock(Observer.class); + final Subscription subscription1 = observable.subscribe(new TestObserver(observer1)); + final Subscription subscription2 = observable.subscribe(new TestObserver(observer2)); + + final InOrder inOrder1 = inOrder(observer1); + final InOrder inOrder2 = inOrder(observer2); + + inOrder1.verify(observer1, never()).onNext(any(OnItemClickEvent.class)); + inOrder2.verify(observer2, never()).onNext(any(OnItemClickEvent.class)); + + subscription1.unsubscribe(); + subscription2.unsubscribe(); + } + + @SuppressWarnings("unchecked") + private void performTestAdapterViewMultipleSubscriptionsClickAllViewsEmitAllEvents(AdapterView adapterView) { + Adapter adapter = adapterView.getAdapter(); + Assert.assertNotNull(adapter); + final Observable observable = ViewObservable.itemClicks(adapterView); + final Observer observer1 = mock(Observer.class); + final Observer observer2 = mock(Observer.class); + final Subscription subscription1 = observable.subscribe(new TestObserver(observer1)); + final Subscription subscription2 = observable.subscribe(new TestObserver(observer2)); + + final InOrder inOrder1 = inOrder(observer1); + final InOrder inOrder2 = inOrder(observer2); + + final int count = adapter.getCount(); + for (int i = 0; i < count; i++) { + adapterView.performItemClick(any(View.class), i, i); + inOrder1.verify(observer1, times(1)).onNext(new OnItemClickEvent(adapterView, any(View.class), i, i)); + inOrder2.verify(observer2, times(1)).onNext(new OnItemClickEvent(adapterView, any(View.class), i, i)); + } + + subscription1.unsubscribe(); + subscription2.unsubscribe(); + } + + @SuppressWarnings("unchecked") + private void performTestAdapterViewMultipleSubscriptionsClickAllViewsEmitAllEventsForOneSubscriber(AdapterView adapterView) { + Adapter adapter = adapterView.getAdapter(); + Assert.assertNotNull(adapter); + final Observable observable = ViewObservable.itemClicks(adapterView); + final Observer observer1 = mock(Observer.class); + final Observer observer2 = mock(Observer.class); + final Subscription subscription1 = observable.subscribe(new TestObserver(observer1)); + final Subscription subscription2 = observable.subscribe(new TestObserver(observer2)); + + final InOrder inOrder1 = inOrder(observer1); + final InOrder inOrder2 = inOrder(observer2); + + inOrder1.verify(observer1, never()).onNext(any(OnItemClickEvent.class)); + inOrder2.verify(observer2, never()).onNext(any(OnItemClickEvent.class)); + + subscription1.unsubscribe(); + + final int count = adapter.getCount(); + for (int i = 0; i < count; i++) { + adapterView.performItemClick(any(View.class), i, i); + inOrder1.verify(observer1, never()).onNext(any(OnItemClickEvent.class)); + inOrder2.verify(observer2, times(1)).onNext(new OnItemClickEvent(adapterView, any(View.class), i, i)); + } + subscription2.unsubscribe(); + } + + @SuppressWarnings("unchecked") + private void performTestAdapterViewMultipleSubscriptionsNeverEmitEventAfterUnsubscribed(AdapterView adapterView) { + Adapter adapter = adapterView.getAdapter(); + Assert.assertNotNull(adapter); + final Observable observable = ViewObservable.itemClicks(adapterView); + final Observer observer1 = mock(Observer.class); + final Observer observer2 = mock(Observer.class); + final Subscription subscription1 = observable.subscribe(new TestObserver(observer1)); + final Subscription subscription2 = observable.subscribe(new TestObserver(observer2)); + + final InOrder inOrder1 = inOrder(observer1); + final InOrder inOrder2 = inOrder(observer2); + + subscription1.unsubscribe(); + subscription2.unsubscribe(); + + inOrder1.verify(observer1, never()).onNext(any(OnItemClickEvent.class)); + inOrder2.verify(observer2, never()).onNext(any(OnItemClickEvent.class)); + } + + @SuppressWarnings("unchecked") + private void performTestAdapterViewMultipleSubscriptionsNeverEmitAnyThrowableAfterUnsubscribed(AdapterView adapterView) { + Adapter adapter = adapterView.getAdapter(); + Assert.assertNotNull(adapter); + final Observable observable = ViewObservable.itemClicks(adapterView); + final Observer observer1 = mock(Observer.class); + final Observer observer2 = mock(Observer.class); + final Subscription subscription1 = observable.subscribe(new TestObserver(observer1)); + final Subscription subscription2 = observable.subscribe(new TestObserver(observer2)); + + final InOrder inOrder1 = inOrder(observer1); + final InOrder inOrder2 = inOrder(observer2); + + subscription1.unsubscribe(); + subscription2.unsubscribe(); + + inOrder1.verify(observer1, never()).onError(any(Throwable.class)); + inOrder2.verify(observer2, never()).onError(any(Throwable.class)); + } + + @SuppressWarnings("unchecked") + private void performTestAdapterViewMultipleSubscriptionsNeverEmitOnCompletedAfterUnsubscribed(AdapterView adapterView) { + Adapter adapter = adapterView.getAdapter(); + Assert.assertNotNull(adapter); + final Observable observable = ViewObservable.itemClicks(adapterView); + final Observer observer1 = mock(Observer.class); + final Observer observer2 = mock(Observer.class); + final Subscription subscription1 = observable.subscribe(new TestObserver(observer1)); + final Subscription subscription2 = observable.subscribe(new TestObserver(observer2)); + + final InOrder inOrder1 = inOrder(observer1); + final InOrder inOrder2 = inOrder(observer2); + + subscription1.unsubscribe(); + subscription2.unsubscribe(); + + inOrder1.verify(observer1, never()).onCompleted(); + inOrder2.verify(observer2, never()).onCompleted(); + } +}