diff --git a/README.md b/README.md index e644fa8..77748c8 100644 --- a/README.md +++ b/README.md @@ -8,9 +8,15 @@ It is similar to a ViewPager, but you can quickly and painlessly create layout, ## Gradle Add this into your dependencies block. ``` -compile 'com.yarolegovich:discrete-scrollview:1.3.2' +compile 'com.yarolegovich:discrete-scrollview:1.5.1' ``` +## Reporting an issue + +If you are going to report an issue, I will greatly appreciate you including some code which I can run to see the issue. By doing so you maximize the chance that I will fix the problem. + +By the way, before reporting a problem, try replacing DiscreteScrollView with a RecyclerView. If the problem is still present, it's likely somewhere in your code. + ## Sample Get it on Google Play
@@ -43,10 +49,11 @@ scrollView.setAdapter(new YourAdapterImplementation()); ``` ### API -#### Layout +#### General ```java -scrollView.setOrientation(Orientation o); //Sets an orientation of the view +scrollView.setOrientation(DSVOrientation o); //Sets an orientation of the view scrollView.setOffscreenItems(count); //Reserve extra space equal to (childSize * count) on each side of the view +scrollView.setOverScrollEnabled(enabled); //Can also be set using android:overScrollMode xml attribute ``` #### Related to the current item: ```java @@ -73,6 +80,10 @@ public interface DiscreteScrollItemTransformer { void transformItem(View item, float position); } ``` +In the above example `view1Position == (currentlySelectedViewPosition - n)` and `view2Position == (currentlySelectedViewPosition + n)`, where `n` defaults to 1 and can be changed using the following API: +```java +scrollView.setClampTransformProgressAfter(n); +``` Because scale transformation is the most common, I included a helper class - ScaleTransformer, here is how to use it: ```java cityPicker.setItemTransformer(new ScaleTransformer.Builder() @@ -118,6 +129,12 @@ int getRealPosition(int position); int getClosestPosition(int position); ``` Currently `InfiniteScrollAdapter` handles data set changes inefficiently, so your contributions are welcome. +#### Disabling scroll +It's possible to forbid user scroll in any or specific direction using: +```java +scrollView.setScrollConfig(config); +``` +Where `config` is an instance of `DSVScrollConfig` enum. The default value enables scroll in any direction. #### Callbacks * Scroll state changes: ```java @@ -188,4 +205,4 @@ 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. -``` \ No newline at end of file +``` diff --git a/build.gradle b/build.gradle index ada7dd2..66fd81e 100644 --- a/build.gradle +++ b/build.gradle @@ -1,10 +1,11 @@ buildscript { repositories { jcenter() + google() } dependencies { - classpath 'com.android.tools.build:gradle:2.3.3' - classpath 'com.novoda:bintray-release:0.4.0' + classpath 'com.android.tools.build:gradle:4.0.1' + classpath 'com.jfrog.bintray.gradle:gradle-bintray-plugin:1.8.5' } } @@ -12,6 +13,8 @@ allprojects { repositories { jcenter() maven { url "https://maven.google.com" } + maven { url "https://jitpack.io" } + google() } } @@ -20,16 +23,27 @@ task clean(type: Delete) { } ext { - userOrg = 'yarolegovich' - groupId = 'com.yarolegovich' - uploadName = 'DiscreteScrollView' - description = 'Scrollable list of items, where current item is centered and can be changed using swipes.' - publishVersion = '1.3.2' - licences = ['Apache-2.0'] + compileSdkVersion = 29 + buildToolsVersion = '29.0.2' + targetSdkVersion = 29 - compileSdkVersion = 26 - buildToolsVersion = '26.0.1' - targetSdkVersion = 25 + deps = [ + recycler : 'androidx.recyclerview:recyclerview:1.0.0', + designSupport : 'com.google.android.material:material:1.0.0', + annotations : 'androidx.annotation:annotation:1.1.0', + androidxCompat: 'androidx.appcompat:appcompat:1.1.0', + glide : 'com.github.bumptech.glide:glide:4.11.0', + materialPrefs : 'com.yarolegovich:mp:1.1.6' + ] - supportLibVersion = '26.0.0' + testDeps = [ + hamcrest : 'org.hamcrest:hamcrest-library:1.3', + mockito : 'org.mockito:mockito-core:2.13.0', + jUnit : 'junit:junit:4.13', + robolectric : 'org.robolectric:robolectric:3.0', + espresso : 'androidx.test.espresso:espresso-core:3.1.0', + androidJUnit: 'androidx.test.ext:junit:1.1.1', + testRules : 'androidx.test:rules:1.1.1', + testRunner : 'androidx.test:runner:1.1.1' + ] } \ No newline at end of file diff --git a/gradle.properties b/gradle.properties index aac7c9b..a28ad2d 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,17 +1,3 @@ -# Project-wide Gradle settings. +android.useAndroidX=true -# IDE (e.g. Android Studio) users: -# Gradle settings configured through the IDE *will override* -# any settings specified in this file. - -# For more details on how to configure your build environment visit -# http://www.gradle.org/docs/current/userguide/build_environment.html - -# Specifies the JVM arguments used for the daemon process. -# The setting is particularly useful for tweaking memory settings. -org.gradle.jvmargs=-Xmx1536m - -# When configured, Gradle will run in incubating parallel mode. -# This option should only be used with decoupled projects. More details, visit -# http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects -# org.gradle.parallel=true +org.gradle.jvmargs=-Xmx1536m \ No newline at end of file diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 094594c..b670cfc 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ -#Wed Apr 12 09:24:26 BST 2017 +#Thu Jul 30 09:08:49 EEST 2020 distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-3.3-all.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-6.1.1-all.zip diff --git a/library/build.gradle b/library/build.gradle index 45ad896..8daa89d 100644 --- a/library/build.gradle +++ b/library/build.gradle @@ -1,29 +1,33 @@ apply plugin: 'com.android.library' -apply plugin: 'com.novoda.bintray-release' +apply from: rootProject.file('release-bintray.gradle') android { - compileSdkVersion rootProject.ext.compileSdkVersion - buildToolsVersion rootProject.ext.buildToolsVersion + compileSdkVersion rootProject.compileSdkVersion + buildToolsVersion rootProject.buildToolsVersion defaultConfig { minSdkVersion 14 - targetSdkVersion rootProject.ext.targetSdkVersion + targetSdkVersion rootProject.targetSdkVersion versionCode 1 versionName "1.0" + + testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" } } dependencies { - compile "com.android.support:appcompat-v7:$supportLibVersion" - compile "com.android.support:recyclerview-v7:$supportLibVersion" -} + implementation deps.recycler + implementation deps.annotations + + testImplementation testDeps.robolectric + testImplementation testDeps.jUnit + testImplementation testDeps.mockito + testImplementation testDeps.hamcrest -publish { - artifactId = 'discrete-scrollview' - userOrg = rootProject.userOrg - groupId = rootProject.groupId - uploadName = rootProject.uploadName - publishVersion = rootProject.publishVersion - description = rootProject.description - licences = rootProject.licences + debugImplementation deps.androidxCompat + androidTestImplementation testDeps.espresso + androidTestImplementation testDeps.androidJUnit + androidTestImplementation testDeps.testRunner + androidTestImplementation testDeps.testRules + androidTestImplementation testDeps.hamcrest } \ No newline at end of file diff --git a/library/src/androidTest/AndroidManifest.xml b/library/src/androidTest/AndroidManifest.xml new file mode 100644 index 0000000..0d29d87 --- /dev/null +++ b/library/src/androidTest/AndroidManifest.xml @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + \ No newline at end of file diff --git a/library/src/androidTest/java/com/yarolegovich/discretescrollview/DataSetModificationTest.java b/library/src/androidTest/java/com/yarolegovich/discretescrollview/DataSetModificationTest.java new file mode 100644 index 0000000..cf22b23 --- /dev/null +++ b/library/src/androidTest/java/com/yarolegovich/discretescrollview/DataSetModificationTest.java @@ -0,0 +1,285 @@ +package com.yarolegovich.discretescrollview; + + +import androidx.test.ext.junit.runners.AndroidJUnit4; + +import com.yarolegovich.discretescrollview.context.TestData; + +import org.junit.Test; +import org.junit.runner.RunWith; + +import java.util.ArrayList; +import java.util.List; + +import static androidx.test.espresso.matcher.ViewMatchers.assertThat; +import static com.yarolegovich.discretescrollview.custom.CustomAssertions.currentPositionIs; +import static com.yarolegovich.discretescrollview.custom.CustomAssertions.doesNotHaveChildren; +import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.greaterThan; +import static org.hamcrest.Matchers.is; + +/** + * Created by yarolegovich on 2/3/18. + */ +@RunWith(AndroidJUnit4.class) +public class DataSetModificationTest extends DiscreteScrollViewTest { + + @Test + public void notifyItemInserted_afterCurrentPosition_currentPositionIsNotAffected() { + final int initialPosition = scrollView.getCurrentItem(); + onUiThread(new Runnable() { + @Override + public void run() { + List data = adapter.getData(); + data.add(initialPosition + 1, new TestData()); + adapter.notifyItemInserted(initialPosition + 1); + } + }); + onScrollView().check(currentPositionIs(initialPosition)); + } + + @Test + public void notifyItemInserted_beforeCurrentPosition_currentPositionIsShifterRightByOne() { + final int initialPosition = scrollView.getCurrentItem(); + onUiThread(new Runnable() { + @Override + public void run() { + List data = adapter.getData(); + data.add(initialPosition, new TestData()); + adapter.notifyItemInserted(initialPosition); + } + }); + onScrollView().check(currentPositionIs(initialPosition + 1)); + } + + @Test + public void notifyItemRemoved_afterCurrentPosition_currentPositionIsNotAffected() { + final int initialPosition = scrollView.getCurrentItem(); + assertThat(adapter.getItemCount(), is(greaterThan(1))); + onUiThread(new Runnable() { + @Override + public void run() { + List data = adapter.getData(); + data.remove(initialPosition + 1); + adapter.notifyItemRemoved(initialPosition + 1); + } + }); + onScrollView().check(currentPositionIs(initialPosition)); + } + + @Test + public void notifyItemRemoved_beforeCurrentPosition_currentPositionIsShifterLeftByOne() { + assertThat(adapter.getItemCount(), is(greaterThan(1))); + final int initialPosition = adapter.getItemCount() / 2; + ensurePositionIs(initialPosition); + onUiThread(new Runnable() { + @Override + public void run() { + List data = adapter.getData(); + data.remove(initialPosition - 1); + adapter.notifyItemRemoved(initialPosition - 1); + } + }); + onScrollView().check(currentPositionIs(initialPosition - 1)); + } + + @Test + public void notifyItemInserted_multipleInsertsBeforeCurrent_currentIsShiftedCorrectly() { + final int numberOfInserts = 5; + final int initialPosition = scrollView.getCurrentItem(); + onUiThread(new Runnable() { + @Override + public void run() { + List data = adapter.getData(); + for (int i = 0; i < numberOfInserts; i++) { + data.add(initialPosition, new TestData()); + adapter.notifyItemInserted(initialPosition); + } + } + }); + onScrollView().check(currentPositionIs(initialPosition + numberOfInserts)); + } + + @Test + public void notifyItemRemoved_calledUntilEmpty_scrollViewIsEmpty() { + onUiThread(new Runnable() { + @Override + public void run() { + List data = adapter.getData(); + while (data.size() > 0) { + data.remove(0); + adapter.notifyItemRemoved(0); + } + } + }); + onScrollView().check(doesNotHaveChildren()); + } + + @Test + public void notifyItemRangeInserted_afterCurrentPosition_positionIsNotAffected() { + final int numOfItemsToInsert = 5; + final int initialPosition = scrollView.getCurrentItem(); + onUiThread(new Runnable() { + @Override + public void run() { + List data = adapter.getData(); + data.addAll(initialPosition + 1, createItems(numOfItemsToInsert)); + adapter.notifyItemRangeInserted(initialPosition + 1, numOfItemsToInsert); + } + }); + onScrollView().check(currentPositionIs(initialPosition)); + } + + @Test + public void notifyItemRangeInserted_beforeCurrentPosition_positionIsShiftedRightByRangeLength() { + final int numOfItemsToInsert = 5; + final int initialPosition = scrollView.getCurrentItem(); + onUiThread(new Runnable() { + @Override + public void run() { + List data = adapter.getData(); + data.addAll(initialPosition, createItems(numOfItemsToInsert)); + adapter.notifyItemRangeInserted(initialPosition, numOfItemsToInsert); + } + }); + onScrollView().check(currentPositionIs(initialPosition + numOfItemsToInsert)); + } + + @Test + public void notifyItemRangeRemoved_afterCurrentPosition_positionIsNotAffected() { + final int initialPosition = scrollView.getCurrentItem(); + final int initialSize = adapter.getItemCount(); + onUiThread(new Runnable() { + @Override + public void run() { + List data = adapter.getData(); + List toRemove = new ArrayList<>(); + for (int i = initialPosition + 1; i < adapter.getItemCount() - 1; i++) { + toRemove.add(data.get(i)); + } + assertThat(toRemove.size(), is(greaterThan(1))); + data.removeAll(toRemove); + assertThat(data.size(), is(equalTo(initialSize - toRemove.size()))); + adapter.notifyItemRangeRemoved(initialPosition + 1, toRemove.size()); + } + }); + onScrollView().check(currentPositionIs(initialPosition)); + } + + @Test + public void notifyItemRangeRemoved_beforeCurrentPosition_positionIsShiftedLeftByRangeLength() { + assertThat(adapter.getItemCount(), is(greaterThan(2))); + final int initialPosition = adapter.getItemCount() - 1; + final int numOfItemsToRemove = adapter.getItemCount() - 1; + ensurePositionIs(initialPosition); + onUiThread(new Runnable() { + @Override + public void run() { + final int initialSize = adapter.getItemCount(); + List data = adapter.getData(); + List toRemove = new ArrayList<>(); + for (int i = initialPosition - 1; i >= 0; i--) { + toRemove.add(data.get(i)); + } + assertThat(toRemove.size(), is(equalTo(numOfItemsToRemove))); + data.removeAll(toRemove); + assertThat(data.size(), is(equalTo(initialSize - toRemove.size()))); + adapter.notifyItemRangeRemoved(0, toRemove.size()); + } + }); + onScrollView().check(currentPositionIs(initialPosition - numOfItemsToRemove)); + } + + @Test + public void notifyDataSetChanged_currentItemRemainsInItemRange_currentIsNotAffected() { + final int initialPosition = 0; + ensurePositionIs(initialPosition); + onUiThread(new Runnable() { + @Override + public void run() { + List data = adapter.getData(); + assertThat(data.size(), is(greaterThan(2))); + data.remove(data.size() - 1); + data.remove(data.size() - 1); + adapter.notifyDataSetChanged(); + } + }); + onScrollView().check(currentPositionIs(initialPosition)); + } + + @Test + public void notifyDataSetChanged_currentItemGoesOutsideItemRange_currentIsClampedToRange() { + final int initialPosition = adapter.getItemCount() - 1; + final int numOfItemsToRemove = 2; + assertThat(adapter.getItemCount(), is(greaterThan(numOfItemsToRemove))); + ensurePositionIs(initialPosition); + onUiThread(new Runnable() { + @Override + public void run() { + List data = adapter.getData(); + data.subList(0, numOfItemsToRemove).clear(); + adapter.notifyDataSetChanged(); + } + }); + onScrollView().check(currentPositionIs(adapter.getItemCount() - 1)); + } + + @Test + public void notifyDataSetChanged_allItemsRemoved_scrollViewIsEmpty() { + onUiThread(new Runnable() { + @Override + public void run() { + adapter.getData().clear(); + adapter.notifyDataSetChanged(); + } + }); + onScrollView().check(doesNotHaveChildren()); + } + + @Test + public void notifyDataSetChanged_scrollToPositionCalledAfterItemsAdded_positionIsCorrect() { + final int targetPosition = adapter.getItemCount(); + onUiThread(new Runnable() { + @Override + public void run() { + List data = adapter.getData(); + final int itemsToAdd = data.size(); + for (int i = 0; i < itemsToAdd; i++) { + data.add(new TestData()); + } + adapter.notifyDataSetChanged(); + scrollView.scrollToPosition(targetPosition); + } + }); + onScrollView().check(currentPositionIs(targetPosition)); + } + + @Test + public void notifyDataSetChanged_scrollToPositionCalledAfterItemsRemoved_positionIsCorrect() { + final int initialPosition = adapter.getItemCount() - 1; + final int targetPosition = adapter.getItemCount() / 4; + assertThat(targetPosition, is(greaterThan(0))); + ensurePositionIs(initialPosition); + onUiThread(new Runnable() { + @Override + public void run() { + List data = adapter.getData(); + final int itemsToRemove = data.size() / 2; + if (itemsToRemove > 0) { + data.subList(0, itemsToRemove).clear(); + } + adapter.notifyDataSetChanged(); + scrollView.scrollToPosition(targetPosition); + } + }); + onScrollView().check(currentPositionIs(targetPosition)); + } + + private List createItems(int count) { + List result = new ArrayList<>(count); + for (int i = 0; i < count; i++) { + result.add(new TestData()); + } + return result; + } +} diff --git a/library/src/androidTest/java/com/yarolegovich/discretescrollview/DiscreteScrollViewTest.java b/library/src/androidTest/java/com/yarolegovich/discretescrollview/DiscreteScrollViewTest.java new file mode 100644 index 0000000..5c12209 --- /dev/null +++ b/library/src/androidTest/java/com/yarolegovich/discretescrollview/DiscreteScrollViewTest.java @@ -0,0 +1,82 @@ +package com.yarolegovich.discretescrollview; + +import android.view.View; + +import androidx.annotation.CallSuper; +import androidx.test.espresso.Espresso; +import androidx.test.espresso.IdlingRegistry; +import androidx.test.espresso.IdlingResource; +import androidx.test.espresso.ViewInteraction; +import androidx.test.rule.ActivityTestRule; + +import com.yarolegovich.discretescrollview.context.TestActivity; +import com.yarolegovich.discretescrollview.context.TestAdapter; + +import org.hamcrest.Matchers; +import org.junit.After; +import org.junit.Before; +import org.junit.Rule; + +import java.util.List; + +import static com.yarolegovich.discretescrollview.custom.CustomAssertions.currentPositionIs; + +/** + * Created by yarolegovich on 2/3/18. + */ + +public abstract class DiscreteScrollViewTest { + + private IdlingResource[] idlingResources; + + protected DiscreteScrollView scrollView; + protected TestAdapter adapter; + + @Rule + public ActivityTestRule testActivity = new ActivityTestRule<>(TestActivity.class); + + @Before + @CallSuper + public void setUp() { + TestActivity activity = testActivity.getActivity(); + scrollView = activity.getScrollView(); + adapter = testActivity.getActivity().getAdapter(); + + List resources = activity.getIdlingResources(); + idlingResources = resources.toArray(new IdlingResource[resources.size()]); + IdlingRegistry.getInstance().register(idlingResources); + } + + @After + @CallSuper + public void tearDown() { + IdlingRegistry.getInstance().unregister(idlingResources); + } + + protected ViewInteraction onScrollView() { + return Espresso.onView(Matchers.is(scrollView)); + } + + protected void waitUntilScrollEnd() { + testActivity.getActivity().incrementExpectedScrollEndCalls(); + } + + protected void ensurePositionIs(final int position) { + onUiThread(new Runnable() { + @Override + public void run() { + scrollView.scrollToPosition(position); + } + }); + onScrollView().check(currentPositionIs(position)); + } + + protected void onUiThread(Runnable runnable) { + try { + testActivity.runOnUiThread(runnable); + } catch (Throwable throwable) { + throw new RuntimeException(throwable); + } + } + +} diff --git a/library/src/androidTest/java/com/yarolegovich/discretescrollview/ScrollFunctionalityTest.java b/library/src/androidTest/java/com/yarolegovich/discretescrollview/ScrollFunctionalityTest.java new file mode 100644 index 0000000..4cadeda --- /dev/null +++ b/library/src/androidTest/java/com/yarolegovich/discretescrollview/ScrollFunctionalityTest.java @@ -0,0 +1,110 @@ +package com.yarolegovich.discretescrollview; + +import androidx.test.ext.junit.runners.AndroidJUnit4; + +import org.junit.Test; +import org.junit.runner.RunWith; + +import static androidx.test.espresso.matcher.ViewMatchers.assertThat; +import static com.yarolegovich.discretescrollview.custom.CustomAssertions.currentPositionIs; +import static org.hamcrest.Matchers.greaterThan; +import static org.hamcrest.Matchers.is; +import static org.hamcrest.Matchers.lessThan; + +/** + * Created by yarolegovich on 2/5/18. + */ +@RunWith(AndroidJUnit4.class) +public class ScrollFunctionalityTest extends DiscreteScrollViewTest { + + @Test + public void scrollToPosition_afterCurrent_changesPosition() { + final int initialPosition = scrollView.getCurrentItem(); + assertThat(initialPosition, is(lessThan(adapter.getItemCount() - 1))); + onUiThread(new Runnable() { + @Override + public void run() { + scrollView.scrollToPosition(initialPosition + 1); + } + }); + onScrollView().check(currentPositionIs(initialPosition + 1)); + } + + @Test + public void scrollToPosition_beforeCurrent_changesPosition() { + assertThat(adapter.getItemCount(), is(greaterThan(1))); + final int initialPosition = adapter.getItemCount() / 2; + ensurePositionIs(initialPosition); + onUiThread(new Runnable() { + @Override + public void run() { + scrollView.scrollToPosition(initialPosition - 1); + } + }); + onScrollView().check(currentPositionIs(initialPosition - 1)); + } + + @Test + public void smoothScrollToPosition_afterCurrent_changesPosition() { + final int initialPosition = scrollView.getCurrentItem(); + assertThat(initialPosition, is(lessThan(adapter.getItemCount() - 1))); + waitUntilScrollEnd(); + onUiThread(new Runnable() { + @Override + public void run() { + scrollView.setItemTransitionTimeMillis(10); + scrollView.smoothScrollToPosition(initialPosition + 1); + } + }); + onScrollView().check(currentPositionIs(initialPosition + 1)); + } + + @Test + public void smoothScrollToPosition_beforeCurrent_changesPosition() { + assertThat(adapter.getItemCount(), is(greaterThan(1))); + final int initialPosition = adapter.getItemCount() / 2; + ensurePositionIs(initialPosition); + waitUntilScrollEnd(); + onUiThread(new Runnable() { + @Override + public void run() { + scrollView.setItemTransitionTimeMillis(10); + scrollView.smoothScrollToPosition(initialPosition - 1); + } + }); + onScrollView().check(currentPositionIs(initialPosition - 1)); + } + + @Test + public void smoothScrollToPosition_throughSeveralPositionsAfterCurrent_changesPosition() { + final int initialPosition = scrollView.getCurrentItem(); + final int targetPosition = adapter.getItemCount() - 1; + assertThat(targetPosition - initialPosition, is(greaterThan(1))); + waitUntilScrollEnd(); + onUiThread(new Runnable() { + @Override + public void run() { + scrollView.setItemTransitionTimeMillis(10); + scrollView.smoothScrollToPosition(targetPosition); + } + }); + onScrollView().check(currentPositionIs(targetPosition)); + } + + @Test + public void smoothScrollToPosition_throughSeveralPositionsBeforeCurrent_changesPosition() { + final int initialPosition = adapter.getItemCount() - 1; + final int targetPosition = 0; + ensurePositionIs(initialPosition); + assertThat(initialPosition, is(greaterThan(0))); + waitUntilScrollEnd(); + onUiThread(new Runnable() { + @Override + public void run() { + scrollView.setItemTransitionTimeMillis(10); + scrollView.smoothScrollToPosition(targetPosition); + } + }); + onScrollView().check(currentPositionIs(targetPosition)); + } +} diff --git a/library/src/androidTest/java/com/yarolegovich/discretescrollview/context/TestActivity.java b/library/src/androidTest/java/com/yarolegovich/discretescrollview/context/TestActivity.java new file mode 100644 index 0000000..aa517f5 --- /dev/null +++ b/library/src/androidTest/java/com/yarolegovich/discretescrollview/context/TestActivity.java @@ -0,0 +1,108 @@ +package com.yarolegovich.discretescrollview.context; + +import android.os.Bundle; +import android.view.Gravity; +import android.view.ViewGroup; +import android.widget.FrameLayout; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.appcompat.app.AppCompatActivity; +import androidx.recyclerview.widget.RecyclerView; +import androidx.test.espresso.IdlingResource; +import androidx.test.espresso.idling.CountingIdlingResource; + +import com.yarolegovich.discretescrollview.DiscreteScrollView; +import com.yarolegovich.discretescrollview.R; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +import static android.view.ViewGroup.LayoutParams.MATCH_PARENT; +import static android.view.ViewGroup.LayoutParams.WRAP_CONTENT; + +/** + * Created by yarolegovich on 2/4/18. + */ + +public class TestActivity extends AppCompatActivity implements DiscreteScrollView.ScrollStateChangeListener { + + private DiscreteScrollView scrollView; + private TestAdapter adapter; + + private CountingIdlingResource expectedScrollEndCalls = new CountingIdlingResource( + "scrollEndCalls" + hashCode(), + true); + + @Override + protected void onCreate(@Nullable Bundle savedInstanceState) { + setTheme(R.style.Theme_AppCompat_Light_NoActionBar); + + super.onCreate(savedInstanceState); + + ViewGroup root = createRootView(); + scrollView = createScrollViewIn(root); + + setContentView(root); + + adapter = new TestAdapter(generateTestData(10)); + scrollView.setAdapter(adapter); + scrollView.addScrollStateChangeListener(this); + } + + public DiscreteScrollView getScrollView() { + return scrollView; + } + + public TestAdapter getAdapter() { + return adapter; + } + + private DiscreteScrollView createScrollViewIn(ViewGroup root) { + DiscreteScrollView scrollView = new DiscreteScrollView(this); + FrameLayout.LayoutParams scrollViewLp = new FrameLayout.LayoutParams(MATCH_PARENT, WRAP_CONTENT); + scrollViewLp.gravity = Gravity.CENTER; + scrollView.setLayoutParams(scrollViewLp); + root.addView(scrollView); + return scrollView; + } + + private ViewGroup createRootView() { + FrameLayout root = new FrameLayout(this); + root.setLayoutParams(new ViewGroup.LayoutParams(MATCH_PARENT, MATCH_PARENT)); + return root; + } + + public void incrementExpectedScrollEndCalls() { + expectedScrollEndCalls.increment(); + } + + @Override + public void onScrollStart(@NonNull RecyclerView.ViewHolder currentItemHolder, int adapterPosition) { + } + + @Override + public void onScrollEnd(@NonNull RecyclerView.ViewHolder currentItemHolder, int adapterPosition) { + if (!expectedScrollEndCalls.isIdleNow()) { + expectedScrollEndCalls.decrement(); + } + } + + @Override + public void onScroll(float scrollPosition, int currentPosition, int newPosition, @Nullable RecyclerView.ViewHolder currentHolder, @Nullable RecyclerView.ViewHolder newCurrent) { + + } + + public @NonNull List getIdlingResources() { + return Collections.singletonList(expectedScrollEndCalls); + } + + private List generateTestData(int size) { + List result = new ArrayList<>(size); + for (int i = 0; i < size; i++) { + result.add(new TestData()); + } + return result; + } +} diff --git a/library/src/androidTest/java/com/yarolegovich/discretescrollview/context/TestAdapter.java b/library/src/androidTest/java/com/yarolegovich/discretescrollview/context/TestAdapter.java new file mode 100644 index 0000000..26665fa --- /dev/null +++ b/library/src/androidTest/java/com/yarolegovich/discretescrollview/context/TestAdapter.java @@ -0,0 +1,71 @@ +package com.yarolegovich.discretescrollview.context; + +import android.view.View; +import android.view.ViewGroup; +import android.widget.ImageView; + +import androidx.annotation.NonNull; +import androidx.recyclerview.widget.RecyclerView; + +import java.util.List; + +/** + * Created by yarolegovich on 2/4/18. + */ + +public class TestAdapter extends RecyclerView.Adapter { + + private List data; + private RecyclerView recyclerView; + + public TestAdapter(List data) { + this.data = data; + } + + @Override + public void onAttachedToRecyclerView(@NonNull RecyclerView recyclerView) { + super.onAttachedToRecyclerView(recyclerView); + this.recyclerView = recyclerView; + } + + @NonNull + @Override + public ViewHolder onCreateViewHolder(ViewGroup parent, int viewType) { + float dp = parent.getResources().getDisplayMetrics().density; + ImageView iv = new ImageView(parent.getContext()); + iv.setLayoutParams(new ViewGroup.LayoutParams((int) (180 * dp), (int) (256 * dp))); + iv.setScaleType(ImageView.ScaleType.CENTER_CROP); + return new ViewHolder(iv); + } + + @Override + public void onBindViewHolder(ViewHolder holder, int position) { + TestData item = data.get(position); + holder.image.setImageDrawable(item.image); + } + + @Override + public int getItemCount() { + return data.size(); + } + + public List getData() { + return data; + } + + class ViewHolder extends RecyclerView.ViewHolder { + + public final ImageView image; + + public ViewHolder(View itemView) { + super(itemView); + image = (ImageView) itemView; + itemView.setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View v) { + recyclerView.smoothScrollToPosition(getAdapterPosition()); + } + }); + } + } +} diff --git a/library/src/androidTest/java/com/yarolegovich/discretescrollview/context/TestData.java b/library/src/androidTest/java/com/yarolegovich/discretescrollview/context/TestData.java new file mode 100644 index 0000000..446eaf5 --- /dev/null +++ b/library/src/androidTest/java/com/yarolegovich/discretescrollview/context/TestData.java @@ -0,0 +1,34 @@ +package com.yarolegovich.discretescrollview.context; + +import android.graphics.Color; +import android.graphics.drawable.ColorDrawable; +import android.graphics.drawable.Drawable; + +import androidx.annotation.ColorInt; + +import java.util.Random; + +/** + * Created by yarolegovich on 2/4/18. + */ + +public class TestData { + + private static int NEXT_ID = 1; + private static final Random random = new Random(); + + public final int id; + public final Drawable image; + public TestData() { + id = NEXT_ID++; + image = new ColorDrawable(generateRandomColor()); + } + + private static @ColorInt + int generateRandomColor() { + return Color.argb(255, + random.nextInt(256), + random.nextInt(256), + random.nextInt(256)); + } +} diff --git a/library/src/androidTest/java/com/yarolegovich/discretescrollview/custom/CustomAssertions.java b/library/src/androidTest/java/com/yarolegovich/discretescrollview/custom/CustomAssertions.java new file mode 100644 index 0000000..99af895 --- /dev/null +++ b/library/src/androidTest/java/com/yarolegovich/discretescrollview/custom/CustomAssertions.java @@ -0,0 +1,73 @@ +package com.yarolegovich.discretescrollview.custom; + +import android.view.View; +import android.view.ViewGroup; + +import androidx.recyclerview.widget.RecyclerView; +import androidx.test.espresso.NoMatchingViewException; +import androidx.test.espresso.ViewAssertion; + +import com.yarolegovich.discretescrollview.DiscreteScrollView; + +import static org.hamcrest.Matchers.*; +import static androidx.test.espresso.matcher.ViewMatchers.*; + + +/** + * Created by yarolegovich on 2/3/18. + */ + +public class CustomAssertions { + + public static ViewAssertion currentPositionIs(final int expectedPosition) { + return new ViewAssertion() { + @Override + public void check(View view, NoMatchingViewException noViewFoundException) { + ensureViewFound(noViewFoundException); + assertThat(view, isAssignableFrom(DiscreteScrollView.class)); + DiscreteScrollView dsv = (DiscreteScrollView) view; + assertThat(dsv.getCurrentItem(), is(equalTo(expectedPosition))); + + View midChild = findCenteredChildIn(dsv); + assertThat(midChild, is(notNullValue())); + RecyclerView.ViewHolder holder = dsv.getChildViewHolder(midChild); + assertThat(holder.getAdapterPosition(), is(equalTo(expectedPosition))); + } + }; + } + + public static ViewAssertion doesNotHaveChildren() { + return new ViewAssertion() { + @Override + public void check(View view, NoMatchingViewException noViewFoundException) { + ensureViewFound(noViewFoundException); + assertThat(view, isAssignableFrom(ViewGroup.class)); + ViewGroup viewGroup = (ViewGroup) view; + assertThat(viewGroup.getChildCount(), is(equalTo(0))); + } + }; + } + + private static View findCenteredChildIn(DiscreteScrollView dsv) { + final int centerX = dsv.getWidth() / 2; + final int centerY = dsv.getHeight() / 2; + for (int i = 0; i < dsv.getChildCount(); i++) { + View child = dsv.getChildAt(i); + if (centerX == (child.getLeft() + child.getWidth() / 2) + && centerY == (child.getTop() + child.getHeight() / 2)) { + return child; + } + } + throw new AssertionError("can't find centered child"); + } + + private static boolean isMidpoint(int value, int rangeStart, int rangeEnd) { + return value == (rangeStart + rangeEnd) / 2; + } + + private static void ensureViewFound(NoMatchingViewException exception) { + if (exception != null) { + throw exception; + } + } +} diff --git a/library/src/main/java/com/yarolegovich/discretescrollview/Orientation.java b/library/src/main/java/com/yarolegovich/discretescrollview/DSVOrientation.java similarity index 90% rename from library/src/main/java/com/yarolegovich/discretescrollview/Orientation.java rename to library/src/main/java/com/yarolegovich/discretescrollview/DSVOrientation.java index 9a5b3e8..8f7d7dc 100644 --- a/library/src/main/java/com/yarolegovich/discretescrollview/Orientation.java +++ b/library/src/main/java/com/yarolegovich/discretescrollview/DSVOrientation.java @@ -1,13 +1,12 @@ package com.yarolegovich.discretescrollview; import android.graphics.Point; -import android.support.v7.widget.RecyclerView; import android.view.View; /** * Created by yarolegovich on 16.03.2017. */ -public enum Orientation { +public enum DSVOrientation { HORIZONTAL { @Override @@ -41,9 +40,9 @@ interface Helper { int getPendingDy(int pendingScroll); - void offsetChildren(int amount, RecyclerView.LayoutManager lm); + void offsetChildren(int amount, RecyclerViewProxy lm); - float getDistanceFromCenter(Point center, int viewCenterX, int viewCenterY); + float getDistanceFromCenter(Point center, float viewCenterX, float viewCenterY); boolean isViewVisible(Point center, int halfWidth, int halfHeight, int endBound, int extraSpace); @@ -100,12 +99,12 @@ public boolean hasNewBecomeVisible(DiscreteScrollLayoutManager lm) { } @Override - public void offsetChildren(int amount, RecyclerView.LayoutManager lm) { - lm.offsetChildrenHorizontal(amount); + public void offsetChildren(int amount, RecyclerViewProxy helper) { + helper.offsetChildrenHorizontal(amount); } @Override - public float getDistanceFromCenter(Point center, int viewCenterX, int viewCenterY) { + public float getDistanceFromCenter(Point center, float viewCenterX, float viewCenterY) { return viewCenterX - center.x; } @@ -161,12 +160,12 @@ public void shiftViewCenter(Direction direction, int shiftAmount, Point outCente } @Override - public void offsetChildren(int amount, RecyclerView.LayoutManager lm) { - lm.offsetChildrenVertical(amount); + public void offsetChildren(int amount, RecyclerViewProxy helper) { + helper.offsetChildrenVertical(amount); } @Override - public float getDistanceFromCenter(Point center, int viewCenterX, int viewCenterY) { + public float getDistanceFromCenter(Point center, float viewCenterX, float viewCenterY) { return viewCenterY - center.y; } @@ -217,5 +216,4 @@ public int getPendingDy(int pendingScroll) { } } - ; } diff --git a/library/src/main/java/com/yarolegovich/discretescrollview/DSVScrollConfig.java b/library/src/main/java/com/yarolegovich/discretescrollview/DSVScrollConfig.java new file mode 100644 index 0000000..f6c743b --- /dev/null +++ b/library/src/main/java/com/yarolegovich/discretescrollview/DSVScrollConfig.java @@ -0,0 +1,30 @@ +package com.yarolegovich.discretescrollview; + +public enum DSVScrollConfig { + ENABLED { + @Override + boolean isScrollBlocked(Direction direction) { + return false; + } + }, + FORWARD_ONLY { + @Override + boolean isScrollBlocked(Direction direction) { + return direction == Direction.START; + } + }, + BACKWARD_ONLY { + @Override + boolean isScrollBlocked(Direction direction) { + return direction == Direction.END; + } + }, + DISABLED { + @Override + boolean isScrollBlocked(Direction direction) { + return true; + } + }; + + abstract boolean isScrollBlocked(Direction direction); +} diff --git a/library/src/main/java/com/yarolegovich/discretescrollview/Direction.java b/library/src/main/java/com/yarolegovich/discretescrollview/Direction.java index e4d1c38..d3fcf49 100644 --- a/library/src/main/java/com/yarolegovich/discretescrollview/Direction.java +++ b/library/src/main/java/com/yarolegovich/discretescrollview/Direction.java @@ -15,6 +15,11 @@ public int applyTo(int delta) { public boolean sameAs(int direction) { return direction < 0; } + + @Override + public Direction reverse() { + return Direction.END; + } }, END { @Override @@ -26,12 +31,19 @@ public int applyTo(int delta) { public boolean sameAs(int direction) { return direction > 0; } + + @Override + public Direction reverse() { + return Direction.START; + } }; public abstract int applyTo(int delta); public abstract boolean sameAs(int direction); + public abstract Direction reverse(); + public static Direction fromDelta(int delta) { return delta > 0 ? END : START; } diff --git a/library/src/main/java/com/yarolegovich/discretescrollview/DiscreteScrollLayoutManager.java b/library/src/main/java/com/yarolegovich/discretescrollview/DiscreteScrollLayoutManager.java index 5a1ed05..c22fba6 100644 --- a/library/src/main/java/com/yarolegovich/discretescrollview/DiscreteScrollLayoutManager.java +++ b/library/src/main/java/com/yarolegovich/discretescrollview/DiscreteScrollLayoutManager.java @@ -5,69 +5,82 @@ import android.graphics.PointF; import android.os.Bundle; import android.os.Parcelable; -import android.support.annotation.NonNull; -import android.support.annotation.Nullable; -import android.support.v4.view.accessibility.AccessibilityEventCompat; -import android.support.v4.view.accessibility.AccessibilityRecordCompat; -import android.support.v7.widget.LinearSmoothScroller; -import android.support.v7.widget.RecyclerView; import android.util.SparseArray; import android.view.View; import android.view.ViewGroup; import android.view.accessibility.AccessibilityEvent; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.recyclerview.widget.LinearSmoothScroller; +import androidx.recyclerview.widget.RecyclerView; + import com.yarolegovich.discretescrollview.transform.DiscreteScrollItemTransformer; +import java.util.Locale; + /** * Created by yarolegovich on 17.02.2017. */ -class DiscreteScrollLayoutManager extends RecyclerView.LayoutManager { +public class DiscreteScrollLayoutManager extends RecyclerView.LayoutManager { static final int NO_POSITION = -1; private static final String EXTRA_POSITION = "extra_position"; private static final int DEFAULT_TIME_FOR_ITEM_SETTLE = 300; private static final int DEFAULT_FLING_THRESHOLD = 2100; //Decrease to increase sensitivity. + private static final int DEFAULT_TRANSFORM_CLAMP_ITEM_COUNT = 1; + + protected static final float SCROLL_TO_SNAP_TO_ANOTHER_ITEM = 0.6f; //This field will take value of all visible view's center points during the fill phase - private Point viewCenterIterator; - private Point recyclerCenter; - private Point currentViewCenter; - private int childHalfWidth, childHalfHeight; - private int extraLayoutSpace; + protected Point viewCenterIterator; + protected Point recyclerCenter; + protected Point currentViewCenter; + protected int childHalfWidth, childHalfHeight; + protected int extraLayoutSpace; //Max possible distance a view can travel during one scroll phase - private int scrollToChangeCurrent; - private int currentScrollState; + protected int scrollToChangeCurrent; + protected int currentScrollState; - private Orientation.Helper orientationHelper; + protected int scrolled; + protected int pendingScroll; + protected int currentPosition; + protected int pendingPosition; - private int scrolled; - private int pendingScroll; - private int currentPosition; - private int pendingPosition; + protected SparseArray detachedCache; + + private DSVOrientation.Helper orientationHelper; + + protected boolean isFirstOrEmptyLayout; private Context context; private int timeForItemSettle; private int offscreenItems; - - private SparseArray detachedCache; + private int transformClampItemCount; private boolean dataSetChangeShiftedPosition; - private boolean isFirstOrEmptyLayout; private int flingThreshold; private boolean shouldSlideOnFling; + private int viewWidth, viewHeight; + + @NonNull + private DSVScrollConfig scrollConfig = DSVScrollConfig.ENABLED; + @NonNull private final ScrollStateListener scrollStateListener; private DiscreteScrollItemTransformer itemTransformer; + private RecyclerViewProxy recyclerViewProxy; + public DiscreteScrollLayoutManager( @NonNull Context c, @NonNull ScrollStateListener scrollStateListener, - @NonNull Orientation orientation) { + @NonNull DSVOrientation orientation) { this.context = c; this.timeForItemSettle = DEFAULT_TIME_FOR_ITEM_SETTLE; this.pendingPosition = NO_POSITION; @@ -80,40 +93,48 @@ public DiscreteScrollLayoutManager( this.detachedCache = new SparseArray<>(); this.scrollStateListener = scrollStateListener; this.orientationHelper = orientation.createHelper(); - setAutoMeasureEnabled(true); + this.recyclerViewProxy = new RecyclerViewProxy(this); + this.transformClampItemCount = DEFAULT_TRANSFORM_CLAMP_ITEM_COUNT; } @Override public void onLayoutChildren(RecyclerView.Recycler recycler, RecyclerView.State state) { if (state.getItemCount() == 0) { - removeAndRecycleAllViews(recycler); + recyclerViewProxy.removeAndRecycleAllViews(recycler); currentPosition = pendingPosition = NO_POSITION; scrolled = pendingScroll = 0; return; } - if (currentPosition == NO_POSITION) { - currentPosition = 0; - } + ensureValidPosition(state); + + updateRecyclerDimensions(state); //onLayoutChildren may be called multiple times and this check is required so that the flag //won't be cleared until onLayoutCompleted if (!isFirstOrEmptyLayout) { - isFirstOrEmptyLayout = getChildCount() == 0; + isFirstOrEmptyLayout = recyclerViewProxy.getChildCount() == 0; if (isFirstOrEmptyLayout) { initChildDimensions(recycler); } } - updateRecyclerDimensions(); - - detachAndScrapAttachedViews(recycler); + recyclerViewProxy.detachAndScrapAttachedViews(recycler); fill(recycler); applyItemTransformToChildren(); } + private void ensureValidPosition(RecyclerView.State state) { + if (currentPosition == NO_POSITION || currentPosition >= state.getItemCount()) { + //currentPosition might have been assigned in onRestoreInstanceState() + //which can lead to a crash (position out of bounds) when data set + //is not persisted across rotations + currentPosition = 0; + } + } + @Override public void onLayoutCompleted(RecyclerView.State state) { if (isFirstOrEmptyLayout) { @@ -125,13 +146,11 @@ public void onLayoutCompleted(RecyclerView.State state) { } } - private void initChildDimensions(RecyclerView.Recycler recycler) { - View viewToMeasure = recycler.getViewForPosition(0); - addView(viewToMeasure); - measureChildWithMargins(viewToMeasure, 0, 0); + protected void initChildDimensions(RecyclerView.Recycler recycler) { + View viewToMeasure = recyclerViewProxy.getMeasuredChildForAdapterPosition(0, recycler); - int childViewWidth = getDecoratedMeasuredWidth(viewToMeasure); - int childViewHeight = getDecoratedMeasuredHeight(viewToMeasure); + int childViewWidth = recyclerViewProxy.getMeasuredWidthWithMargin(viewToMeasure); + int childViewHeight = recyclerViewProxy.getMeasuredHeightWithMargin(viewToMeasure); childHalfWidth = childViewWidth / 2; childHalfHeight = childViewHeight / 2; @@ -142,19 +161,31 @@ private void initChildDimensions(RecyclerView.Recycler recycler) { extraLayoutSpace = scrollToChangeCurrent * offscreenItems; - detachAndScrapView(viewToMeasure, recycler); + recyclerViewProxy.detachAndScrapView(viewToMeasure, recycler); } - private void updateRecyclerDimensions() { - recyclerCenter.set(getWidth() / 2, getHeight() / 2); + protected void updateRecyclerDimensions(RecyclerView.State state) { + boolean dimensionsChanged = !state.isMeasuring() + && (recyclerViewProxy.getWidth() != viewWidth + || recyclerViewProxy.getHeight() != viewHeight); + if (dimensionsChanged) { + viewWidth = recyclerViewProxy.getWidth(); + viewHeight = recyclerViewProxy.getHeight(); + recyclerViewProxy.removeAllViews(); + } + recyclerCenter.set( + recyclerViewProxy.getWidth() / 2, + recyclerViewProxy.getHeight() / 2); } - private void fill(RecyclerView.Recycler recycler) { + protected void fill(RecyclerView.Recycler recycler) { cacheAndDetachAttachedViews(); orientationHelper.setCurrentViewCenter(recyclerCenter, scrolled, currentViewCenter); - final int endBound = orientationHelper.getViewEnd(getWidth(), getHeight()); + final int endBound = orientationHelper.getViewEnd( + recyclerViewProxy.getWidth(), + recyclerViewProxy.getHeight()); //Layout current if (isViewVisible(currentViewCenter, endBound)) { @@ -167,7 +198,7 @@ private void fill(RecyclerView.Recycler recycler) { //Layout items after the current item layoutViews(recycler, Direction.END, endBound); - recycleViewsAndClearCache(recycler); + recycleDetachedViewsAndClearCache(recycler); } private void layoutViews(RecyclerView.Recycler recycler, Direction direction, int endBound) { @@ -191,57 +222,55 @@ private void layoutViews(RecyclerView.Recycler recycler, Direction direction, in } } - private void layoutView(RecyclerView.Recycler recycler, int position, Point viewCenter) { + protected void layoutView(RecyclerView.Recycler recycler, int position, Point viewCenter) { if (position < 0) return; View v = detachedCache.get(position); if (v == null) { - v = recycler.getViewForPosition(position); - addView(v); - measureChildWithMargins(v, 0, 0); - layoutDecoratedWithMargins(v, + v = recyclerViewProxy.getMeasuredChildForAdapterPosition(position, recycler); + recyclerViewProxy.layoutDecoratedWithMargins(v, viewCenter.x - childHalfWidth, viewCenter.y - childHalfHeight, viewCenter.x + childHalfWidth, viewCenter.y + childHalfHeight); } else { - attachView(v); + recyclerViewProxy.attachView(v); detachedCache.remove(position); } } - private void cacheAndDetachAttachedViews() { + protected void cacheAndDetachAttachedViews() { detachedCache.clear(); - for (int i = 0; i < getChildCount(); i++) { - View child = getChildAt(i); - detachedCache.put(getPosition(child), child); + for (int i = 0; i < recyclerViewProxy.getChildCount(); i++) { + View child = recyclerViewProxy.getChildAt(i); + detachedCache.put(recyclerViewProxy.getPosition(child), child); } for (int i = 0; i < detachedCache.size(); i++) { - detachView(detachedCache.valueAt(i)); + recyclerViewProxy.detachView(detachedCache.valueAt(i)); } } - private void recycleViewsAndClearCache(RecyclerView.Recycler recycler) { + protected void recycleDetachedViewsAndClearCache(RecyclerView.Recycler recycler) { for (int i = 0; i < detachedCache.size(); i++) { View viewToRemove = detachedCache.valueAt(i); - recycler.recycleView(viewToRemove); + recyclerViewProxy.recycleView(viewToRemove, recycler); } detachedCache.clear(); } @Override - public void onItemsAdded(RecyclerView recyclerView, int positionStart, int itemCount) { + public void onItemsAdded(@NonNull RecyclerView recyclerView, int positionStart, int itemCount) { int newPosition = currentPosition; if (currentPosition == NO_POSITION) { newPosition = 0; } else if (currentPosition >= positionStart) { - newPosition = Math.min(currentPosition + itemCount, getItemCount() - 1); + newPosition = Math.min(currentPosition + itemCount, recyclerViewProxy.getItemCount() - 1); } onNewPosition(newPosition); } @Override - public void onItemsRemoved(RecyclerView recyclerView, int positionStart, int itemCount) { + public void onItemsRemoved(@NonNull RecyclerView recyclerView, int positionStart, int itemCount) { int newPosition = currentPosition; - if (getItemCount() == 0) { + if (recyclerViewProxy.getItemCount() == 0) { newPosition = NO_POSITION; } else if (currentPosition >= positionStart) { if (currentPosition < positionStart + itemCount) { @@ -254,9 +283,9 @@ public void onItemsRemoved(RecyclerView recyclerView, int positionStart, int ite } @Override - public void onItemsChanged(RecyclerView recyclerView) { + public void onItemsChanged(@NonNull RecyclerView recyclerView) { //notifyDataSetChanged() was called. We need to ensure that currentPosition is not out of bounds - currentPosition = Math.min(Math.max(0, currentPosition), getItemCount() - 1); + currentPosition = Math.min(Math.max(0, currentPosition), recyclerViewProxy.getItemCount() - 1); dataSetChangeShiftedPosition = true; } @@ -277,8 +306,8 @@ public int scrollVerticallyBy(int dy, RecyclerView.Recycler recycler, RecyclerVi return scrollBy(dy, recycler); } - private int scrollBy(int amount, RecyclerView.Recycler recycler) { - if (getChildCount() == 0) { + protected int scrollBy(int amount, RecyclerView.Recycler recycler) { + if (recyclerViewProxy.getChildCount() == 0) { return 0; } @@ -294,7 +323,7 @@ private int scrollBy(int amount, RecyclerView.Recycler recycler) { pendingScroll -= delta; } - orientationHelper.offsetChildren(-delta, this); + orientationHelper.offsetChildren(-delta, recyclerViewProxy); if (orientationHelper.hasNewBecomeVisible(this)) { fill(recycler); @@ -307,11 +336,13 @@ private int scrollBy(int amount, RecyclerView.Recycler recycler) { return delta; } - private void applyItemTransformToChildren() { + protected void applyItemTransformToChildren() { if (itemTransformer != null) { - for (int i = 0; i < getChildCount(); i++) { - View child = getChildAt(i); - itemTransformer.transformItem(child, getCenterRelativePositionOf(child)); + int clampAfterDistance = scrollToChangeCurrent * transformClampItemCount; + for (int i = 0; i < recyclerViewProxy.getChildCount(); i++) { + View child = recyclerViewProxy.getChildAt(i); + float position = getCenterRelativePositionOf(child, clampAfterDistance); + itemTransformer.transformItem(child, position); } } } @@ -323,7 +354,7 @@ public void scrollToPosition(int position) { } currentPosition = position; - requestLayout(); + recyclerViewProxy.requestLayout(); } @Override @@ -331,7 +362,13 @@ public void smoothScrollToPosition(RecyclerView recyclerView, RecyclerView.State if (currentPosition == position || pendingPosition != NO_POSITION) { return; } - startSmoothPendingScroll(position); + checkTargetPosition(state, position); + if (currentPosition == NO_POSITION) { + //Layout not happened yet + currentPosition = position; + } else { + startSmoothPendingScroll(position); + } } @Override @@ -415,6 +452,12 @@ private void onDragStart() { pendingScroll = 0; } + public boolean isFlingDisallowed(int velocityX, int velocityY) { + int velocity = orientationHelper.getFlingVelocity(velocityX, velocityY); + Direction direction = Direction.fromDelta(velocity); + return scrollConfig.isScrollBlocked(direction); + } + public void onFling(int velocityX, int velocityY) { int velocity = orientationHelper.getFlingVelocity(velocityX, velocityY); int throttleValue = shouldSlideOnFling ? Math.abs(velocity / flingThreshold) : 1; @@ -436,10 +479,15 @@ public void returnToCurrentPosition() { } } - private int calculateAllowedScrollIn(Direction direction) { + protected int calculateAllowedScrollIn(Direction direction) { if (pendingScroll != 0) { return Math.abs(pendingScroll); } + if (currentScrollState == RecyclerView.SCROLL_STATE_DRAGGING) { + if (scrollConfig.isScrollBlocked(direction)) { + return direction.reverse().applyTo(scrolled); + } + } int allowedScroll; boolean isBoundReached; boolean isScrollDirectionAsBefore = direction.applyTo(scrolled) > 0; @@ -447,7 +495,7 @@ private int calculateAllowedScrollIn(Direction direction) { //We can scroll to the left when currentPosition == 0 only if we scrolled to the right before isBoundReached = scrolled == 0; allowedScroll = isBoundReached ? 0 : Math.abs(scrolled); - } else if (direction == Direction.END && currentPosition == getItemCount() - 1) { + } else if (direction == Direction.END && currentPosition == recyclerViewProxy.getItemCount() - 1) { //We can scroll to the right when currentPosition == last only if we scrolled to the left before isBoundReached = scrolled == 0; allowedScroll = isBoundReached ? 0 : Math.abs(scrolled); @@ -464,10 +512,10 @@ private int calculateAllowedScrollIn(Direction direction) { private void startSmoothPendingScroll() { LinearSmoothScroller scroller = new DiscreteLinearSmoothScroller(context); scroller.setTargetPosition(currentPosition); - startSmoothScroll(scroller); + recyclerViewProxy.startSmoothScroll(scroller); } - private void startSmoothPendingScroll(int position){ + private void startSmoothPendingScroll(int position) { if (currentPosition == position) return; pendingScroll = -scrolled; Direction direction = Direction.fromDelta(position - currentPosition); @@ -477,13 +525,73 @@ private void startSmoothPendingScroll(int position){ startSmoothPendingScroll(); } + @Override + public boolean isAutoMeasureEnabled() { + return true; + } + + @Override + public int computeVerticalScrollRange(@NonNull RecyclerView.State state) { + return computeScrollRange(state); + } + + @Override + public int computeVerticalScrollOffset(@NonNull RecyclerView.State state) { + return computeScrollOffset(state); + } + + @Override + public int computeVerticalScrollExtent(@NonNull RecyclerView.State state) { + return computeScrollExtent(state); + } + + @Override + public int computeHorizontalScrollRange(@NonNull RecyclerView.State state) { + return computeScrollRange(state); + } + + @Override + public int computeHorizontalScrollOffset(@NonNull RecyclerView.State state) { + return computeScrollOffset(state); + } + + @Override + public int computeHorizontalScrollExtent(@NonNull RecyclerView.State state) { + return computeScrollExtent(state); + } + + private int computeScrollOffset(RecyclerView.State state) { + int scrollbarSize = computeScrollExtent(state); + int offset = (int) ((scrolled / (float) scrollToChangeCurrent) * scrollbarSize); + return (currentPosition * scrollbarSize) + offset; + } + + private int computeScrollExtent(RecyclerView.State state) { + if (getItemCount() == 0) { + return 0; + } else { + return (int) (computeScrollRange(state) / (float) getItemCount()); + } + } + + private int computeScrollRange(RecyclerView.State state) { + if (state.getItemCount() == 0) { + return 0; + } else { + return scrollToChangeCurrent * (state.getItemCount() - 1); + } + } + @Override public void onAdapterChanged(RecyclerView.Adapter oldAdapter, RecyclerView.Adapter newAdapter) { pendingPosition = NO_POSITION; scrolled = pendingScroll = 0; - currentPosition = 0; - - removeAllViews(); + if (newAdapter instanceof InitialPositionProvider) { + currentPosition = ((InitialPositionProvider) newAdapter).getInitialPosition(); + } else { + currentPosition = 0; + } + recyclerViewProxy.removeAllViews(); } @Override @@ -530,52 +638,63 @@ public void setTimeForItemSettle(int timeForItemSettle) { public void setOffscreenItems(int offscreenItems) { this.offscreenItems = offscreenItems; extraLayoutSpace = scrollToChangeCurrent * offscreenItems; - requestLayout(); + recyclerViewProxy.requestLayout(); + } + + public void setTransformClampItemCount(int transformClampItemCount) { + this.transformClampItemCount = transformClampItemCount; + applyItemTransformToChildren(); } - public void setOrientation(Orientation orientation) { + public void setOrientation(DSVOrientation orientation) { orientationHelper = orientation.createHelper(); - removeAllViews(); - requestLayout(); + recyclerViewProxy.removeAllViews(); + recyclerViewProxy.requestLayout(); } - public void setShouldSlideOnFling(boolean result){ + public void setShouldSlideOnFling(boolean result) { shouldSlideOnFling = result; } - public void setSlideOnFlingThreshold(int threshold){ + public void setSlideOnFlingThreshold(int threshold) { flingThreshold = threshold; } + public void setScrollConfig(@NonNull DSVScrollConfig config) { + scrollConfig = config; + } + public int getCurrentPosition() { return currentPosition; } @Override - public void onInitializeAccessibilityEvent(AccessibilityEvent event) { + public void onInitializeAccessibilityEvent(@NonNull AccessibilityEvent event) { super.onInitializeAccessibilityEvent(event); - if (getChildCount() > 0) { - final AccessibilityRecordCompat record = AccessibilityEventCompat.asRecord(event); - record.setFromIndex(getPosition(getFirstChild())); - record.setToIndex(getPosition(getLastChild())); + if (recyclerViewProxy.getChildCount() > 0) { + event.setFromIndex(getPosition(getFirstChild())); + event.setToIndex(getPosition(getLastChild())); } } - private float getCenterRelativePositionOf(View v) { + private float getCenterRelativePositionOf(View v, int maxDistance) { + float childCenterX = getDecoratedLeft(v) + v.getWidth() * 0.5f; + float childCenterY = getDecoratedTop(v) + v.getHeight() * 0.5f; float distanceFromCenter = orientationHelper.getDistanceFromCenter(recyclerCenter, - getDecoratedLeft(v) + childHalfWidth, - getDecoratedTop(v) + childHalfHeight); - return Math.min(Math.max(-1f, distanceFromCenter / scrollToChangeCurrent), 1f); + childCenterX, + childCenterY); + return Math.min(Math.max(-1f, distanceFromCenter / maxDistance), 1f); } private int checkNewOnFlingPositionIsInBounds(int position) { + final int itemCount = recyclerViewProxy.getItemCount(); //The check is required in case slide through multiple items is turned on if (currentPosition != 0 && position < 0) { //If currentPosition == 0 && position < 0 we forbid scroll to the left, //but if currentPosition != 0 we can slide to the first item return 0; - } else if (currentPosition != getItemCount() - 1 && position >= getItemCount()) { - return getItemCount() - 1; + } else if (currentPosition != itemCount - 1 && position >= itemCount) { + return itemCount - 1; } return position; } @@ -585,15 +704,15 @@ private int getHowMuchIsLeftToScroll(int dx) { } private boolean isAnotherItemCloserThanCurrent() { - return Math.abs(scrolled) >= scrollToChangeCurrent * 0.6f; + return Math.abs(scrolled) >= scrollToChangeCurrent * SCROLL_TO_SNAP_TO_ANOTHER_ITEM; } public View getFirstChild() { - return getChildAt(0); + return recyclerViewProxy.getChildAt(0); } public View getLastChild() { - return getChildAt(getChildCount() - 1); + return recyclerViewProxy.getChildAt(recyclerViewProxy.getChildCount() - 1); } public int getExtraLayoutSpace() { @@ -602,14 +721,14 @@ public int getExtraLayoutSpace() { private void notifyScroll() { float amountToScroll = pendingPosition != NO_POSITION ? - Math.abs(scrolled + pendingScroll) : - scrollToChangeCurrent; + Math.abs(scrolled + pendingScroll) : + scrollToChangeCurrent; float position = -Math.min(Math.max(-1f, scrolled / amountToScroll), 1f); scrollStateListener.onScroll(position); } private boolean isInBounds(int itemPosition) { - return itemPosition >= 0 && itemPosition < getItemCount(); + return itemPosition >= 0 && itemPosition < recyclerViewProxy.getItemCount(); } private boolean isViewVisible(Point viewCenter, int endBound) { @@ -618,6 +737,22 @@ private boolean isViewVisible(Point viewCenter, int endBound) { endBound, extraLayoutSpace); } + private void checkTargetPosition(RecyclerView.State state, int targetPosition) { + if (targetPosition < 0 || targetPosition >= state.getItemCount()) { + throw new IllegalArgumentException(String.format(Locale.US, + "target position out of bounds: position=%d, itemCount=%d", + targetPosition, state.getItemCount())); + } + } + + protected void setRecyclerViewProxy(RecyclerViewProxy recyclerViewProxy) { + this.recyclerViewProxy = recyclerViewProxy; + } + + protected void setOrientationHelper(DSVOrientation.Helper orientationHelper) { + this.orientationHelper = orientationHelper; + } + private class DiscreteLinearSmoothScroller extends LinearSmoothScroller { public DiscreteLinearSmoothScroller(Context context) { @@ -663,4 +798,7 @@ public interface ScrollStateListener { void onDataSetChangeChangedPosition(); } + public interface InitialPositionProvider { + int getInitialPosition(); + } } diff --git a/library/src/main/java/com/yarolegovich/discretescrollview/DiscreteScrollView.java b/library/src/main/java/com/yarolegovich/discretescrollview/DiscreteScrollView.java index 540e44d..560e5d0 100644 --- a/library/src/main/java/com/yarolegovich/discretescrollview/DiscreteScrollView.java +++ b/library/src/main/java/com/yarolegovich/discretescrollview/DiscreteScrollView.java @@ -2,13 +2,14 @@ import android.content.Context; import android.content.res.TypedArray; -import android.support.annotation.IntRange; -import android.support.annotation.NonNull; -import android.support.annotation.Nullable; -import android.support.v7.widget.RecyclerView; import android.util.AttributeSet; import android.view.View; +import androidx.annotation.IntRange; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.recyclerview.widget.RecyclerView; + import com.yarolegovich.discretescrollview.transform.DiscreteScrollItemTransformer; import com.yarolegovich.discretescrollview.util.ScrollListenerAdapter; @@ -18,17 +19,25 @@ /** * Created by yarolegovich on 18.02.2017. */ -@SuppressWarnings("unchecked") +@SuppressWarnings({"unchecked", "rawtypes"}) public class DiscreteScrollView extends RecyclerView { public static final int NO_POSITION = DiscreteScrollLayoutManager.NO_POSITION; - private static final int DEFAULT_ORIENTATION = Orientation.HORIZONTAL.ordinal(); + private static final int DEFAULT_ORIENTATION = DSVOrientation.HORIZONTAL.ordinal(); private DiscreteScrollLayoutManager layoutManager; private List scrollStateChangeListeners; private List onItemChangedListeners; + private Runnable notifyItemChangedRunnable = new Runnable() { + @Override + public void run() { + notifyCurrentItemChanged(); + } + }; + + private boolean isOverScrollEnabled; public DiscreteScrollView(Context context) { super(context); @@ -56,9 +65,11 @@ private void init(AttributeSet attrs) { ta.recycle(); } + isOverScrollEnabled = getOverScrollMode() != OVER_SCROLL_NEVER; + layoutManager = new DiscreteScrollLayoutManager( getContext(), new ScrollStateListener(), - Orientation.values()[orientation]); + DSVOrientation.values()[orientation]); setLayoutManager(layoutManager); } @@ -74,6 +85,9 @@ public void setLayoutManager(LayoutManager layout) { @Override public boolean fling(int velocityX, int velocityY) { + if (layoutManager.isFlingDisallowed(velocityX, velocityY)) { + return false; + } boolean isFling = super.fling(velocityX, velocityY); if (isFling) { layoutManager.onFling(velocityX, velocityY); @@ -89,6 +103,15 @@ public ViewHolder getViewHolder(int position) { return view != null ? getChildViewHolder(view) : null; } + @Override + public void scrollToPosition(int position) { + int currentPosition = layoutManager.getCurrentPosition(); + super.scrollToPosition(position); + if (currentPosition != position) { + notifyCurrentItemChanged(); + } + } + /** * @return adapter position of the current item or -1 if nothing is selected */ @@ -112,7 +135,7 @@ public void setSlideOnFlingThreshold(int threshold){ layoutManager.setSlideOnFlingThreshold(threshold); } - public void setOrientation(Orientation orientation) { + public void setOrientation(DSVOrientation orientation) { layoutManager.setOrientation(orientation); } @@ -120,6 +143,22 @@ public void setOffscreenItems(int items) { layoutManager.setOffscreenItems(items); } + public void setScrollConfig(@NonNull DSVScrollConfig config) { + layoutManager.setScrollConfig(config); + } + + public void setClampTransformProgressAfter(@IntRange(from = 1) int itemCount) { + if (itemCount <= 1) { + throw new IllegalArgumentException("must be >= 1"); + } + layoutManager.setTransformClampItemCount(itemCount); + } + + public void setOverScrollEnabled(boolean overScrollEnabled) { + isOverScrollEnabled = overScrollEnabled; + setOverScrollMode(OVER_SCROLL_NEVER); + } + public void addScrollStateChangeListener(@NonNull ScrollStateChangeListener scrollStateChangeListener) { scrollStateChangeListeners.add(scrollStateChangeListener); } @@ -173,23 +212,31 @@ private void notifyCurrentItemChanged(ViewHolder holder, int current) { } private void notifyCurrentItemChanged() { + removeCallbacks(notifyItemChangedRunnable); if (onItemChangedListeners.isEmpty()) { return; } int current = layoutManager.getCurrentPosition(); ViewHolder currentHolder = getViewHolder(current); - notifyCurrentItemChanged(currentHolder, current); + if (currentHolder == null) { + post(notifyItemChangedRunnable); + } else { + notifyCurrentItemChanged(currentHolder, current); + } } private class ScrollStateListener implements DiscreteScrollLayoutManager.ScrollStateListener { @Override public void onIsBoundReachedFlagChange(boolean isBoundReached) { - setOverScrollMode(isBoundReached ? OVER_SCROLL_ALWAYS : OVER_SCROLL_NEVER); + if (isOverScrollEnabled) { + setOverScrollMode(isBoundReached ? OVER_SCROLL_ALWAYS : OVER_SCROLL_NEVER); + } } @Override public void onScrollStart() { + removeCallbacks(notifyItemChangedRunnable); if (scrollStateChangeListeners.isEmpty()) { return; } @@ -230,12 +277,7 @@ public void onScroll(float currentViewPosition) { @Override public void onCurrentViewFirstLayout() { - post(new Runnable() { - @Override - public void run() { - notifyCurrentItemChanged(); - } - }); + notifyCurrentItemChanged(); } @Override diff --git a/library/src/main/java/com/yarolegovich/discretescrollview/InfiniteScrollAdapter.java b/library/src/main/java/com/yarolegovich/discretescrollview/InfiniteScrollAdapter.java index e54dab2..803efd5 100644 --- a/library/src/main/java/com/yarolegovich/discretescrollview/InfiniteScrollAdapter.java +++ b/library/src/main/java/com/yarolegovich/discretescrollview/InfiniteScrollAdapter.java @@ -1,16 +1,20 @@ package com.yarolegovich.discretescrollview; -import android.support.annotation.NonNull; -import android.support.v7.widget.RecyclerView; import android.view.ViewGroup; +import androidx.annotation.NonNull; +import androidx.recyclerview.widget.RecyclerView; + +import java.util.Locale; + /** * Created by yarolegovich on 28-Apr-17. */ -public class InfiniteScrollAdapter extends RecyclerView.Adapter { +public class InfiniteScrollAdapter extends RecyclerView.Adapter + implements DiscreteScrollLayoutManager.InitialPositionProvider { - private static final int NOT_INITIALIZED = -1; + private static final int CENTER = Integer.MAX_VALUE / 2; private static final int RESET_BOUND = 100; public static InfiniteScrollAdapter wrap( @@ -21,19 +25,16 @@ public static InfiniteScrollAdapter wrap( private RecyclerView.Adapter wrapped; private DiscreteScrollLayoutManager layoutManager; - private int currentRangeStart; - public InfiniteScrollAdapter(@NonNull RecyclerView.Adapter wrapped) { this.wrapped = wrapped; this.wrapped.registerAdapterDataObserver(new DataSetChangeDelegate()); } @Override - public void onAttachedToRecyclerView(RecyclerView recyclerView) { + public void onAttachedToRecyclerView(@NonNull RecyclerView recyclerView) { wrapped.onAttachedToRecyclerView(recyclerView); if (recyclerView instanceof DiscreteScrollView) { layoutManager = (DiscreteScrollLayoutManager) recyclerView.getLayoutManager(); - currentRangeStart = NOT_INITIALIZED; } else { String msg = recyclerView.getContext().getString(R.string.dsv_ex_msg_adapter_wrong_recycler); throw new RuntimeException(msg); @@ -41,21 +42,23 @@ public void onAttachedToRecyclerView(RecyclerView recyclerView) { } @Override - public void onDetachedFromRecyclerView(RecyclerView recyclerView) { + public void onDetachedFromRecyclerView(@NonNull RecyclerView recyclerView) { wrapped.onDetachedFromRecyclerView(recyclerView); layoutManager = null; } @Override - public T onCreateViewHolder(ViewGroup parent, int viewType) { - if (currentRangeStart == NOT_INITIALIZED) { - resetRange(0); - } + public @NonNull T onCreateViewHolder(@NonNull ViewGroup parent, int viewType) { return wrapped.onCreateViewHolder(parent, viewType); } @Override - public void onBindViewHolder(T holder, int position) { + public void onBindViewHolder(@NonNull T holder, int position) { + if (isResetRequired(position)) { + int resetPosition = CENTER + mapPositionToReal(layoutManager.getCurrentPosition()); + setPosition(resetPosition); + return; + } wrapped.onBindViewHolder(holder, mapPositionToReal(position)); } @@ -66,7 +69,7 @@ public int getItemViewType(int position) { @Override public int getItemCount() { - return wrapped.getItemCount() == 0 ? 0 : Integer.MAX_VALUE; + return isInfinite() ? Integer.MAX_VALUE : wrapped.getItemCount(); } public int getRealItemCount() { @@ -82,43 +85,61 @@ public int getRealPosition(int position) { } public int getClosestPosition(int position) { - int adapterTarget = currentRangeStart + position; + ensureValidPosition(position); int adapterCurrent = layoutManager.getCurrentPosition(); - if (adapterTarget == adapterCurrent) { + int current = mapPositionToReal(adapterCurrent); + if (position == current) { return adapterCurrent; - } else if (adapterTarget < adapterCurrent) { - int adapterTargetNextSet = currentRangeStart + wrapped.getItemCount() + position; - return adapterCurrent - adapterTarget < adapterTargetNextSet - adapterCurrent ? - adapterTarget : adapterTargetNextSet; + } + int delta = position - current; + int target = adapterCurrent + delta; + int wraparoundTarget = adapterCurrent + (position > current ? + delta - wrapped.getItemCount() : + wrapped.getItemCount() + delta); + int distance = Math.abs(adapterCurrent - target); + int wraparoundDistance = Math.abs(adapterCurrent - wraparoundTarget); + if (distance == wraparoundDistance) { + //Scroll to the right feels more natural, so prefer it + return target > adapterCurrent ? target : wraparoundTarget; } else { - int adapterTargetPrevSet = currentRangeStart - wrapped.getItemCount() + position; - return adapterCurrent - adapterTargetPrevSet < adapterTarget - adapterCurrent ? - adapterTargetPrevSet : adapterTarget; + return distance < wraparoundDistance ? target : wraparoundTarget; } } private int mapPositionToReal(int position) { - int newPosition = position - currentRangeStart; - if (newPosition >= wrapped.getItemCount()) { - currentRangeStart += wrapped.getItemCount(); - if (Integer.MAX_VALUE - currentRangeStart <= RESET_BOUND) { - resetRange(0); - } - return 0; - } else if (newPosition < 0) { - currentRangeStart -= wrapped.getItemCount(); - if (currentRangeStart <= RESET_BOUND) { - resetRange(wrapped.getItemCount() - 1); - } - return wrapped.getItemCount() - 1; + if (position < CENTER) { + int rem = (CENTER - position) % wrapped.getItemCount(); + return rem == 0 ? 0 : wrapped.getItemCount() - rem; } else { - return newPosition; + return (position - CENTER) % wrapped.getItemCount(); } } - private void resetRange(int newPosition) { - currentRangeStart = Integer.MAX_VALUE / 2; - layoutManager.scrollToPosition(currentRangeStart + newPosition); + private boolean isResetRequired(int requestedPosition) { + return isInfinite() + && (requestedPosition <= RESET_BOUND + || requestedPosition >= (Integer.MAX_VALUE - RESET_BOUND)); + } + + private void ensureValidPosition(int position) { + if (position >= wrapped.getItemCount()) { + throw new IndexOutOfBoundsException(String.format(Locale.US, + "requested position is outside adapter's bounds: position=%d, size=%d", + position, wrapped.getItemCount())); + } + } + + private boolean isInfinite() { + return wrapped.getItemCount() > 1; + } + + @Override + public int getInitialPosition() { + return isInfinite() ? CENTER : 0; + } + + private void setPosition(int position) { + layoutManager.scrollToPosition(position); } //TODO: handle proper data set change notifications @@ -126,7 +147,7 @@ private class DataSetChangeDelegate extends RecyclerView.AdapterDataObserver { @Override public void onChanged() { - resetRange(0); + setPosition(getInitialPosition()); notifyDataSetChanged(); } @@ -136,23 +157,23 @@ public void onItemRangeRemoved(int positionStart, int itemCount) { } @Override - public void onItemRangeChanged(int positionStart, int itemCount) { + public void onItemRangeInserted(int positionStart, int itemCount) { onChanged(); } @Override - public void onItemRangeChanged(int positionStart, int itemCount, Object payload) { + public void onItemRangeMoved(int fromPosition, int toPosition, int itemCount) { onChanged(); } @Override - public void onItemRangeInserted(int positionStart, int itemCount) { - onChanged(); + public void onItemRangeChanged(int positionStart, int itemCount) { + notifyItemRangeChanged(0, getItemCount()); } @Override - public void onItemRangeMoved(int fromPosition, int toPosition, int itemCount) { - onChanged(); + public void onItemRangeChanged(int positionStart, int itemCount, Object payload) { + notifyItemRangeChanged(0, getItemCount(), payload); } } } \ No newline at end of file diff --git a/library/src/main/java/com/yarolegovich/discretescrollview/RecyclerViewProxy.java b/library/src/main/java/com/yarolegovich/discretescrollview/RecyclerViewProxy.java new file mode 100644 index 0000000..7aac486 --- /dev/null +++ b/library/src/main/java/com/yarolegovich/discretescrollview/RecyclerViewProxy.java @@ -0,0 +1,108 @@ +package com.yarolegovich.discretescrollview; + +import android.view.View; +import android.view.ViewGroup; + +import androidx.annotation.NonNull; +import androidx.recyclerview.widget.RecyclerView; + +/** + * Created by yarolegovich on 10/25/17. + */ +public class RecyclerViewProxy { + + private RecyclerView.LayoutManager layoutManager; + + public RecyclerViewProxy(@NonNull RecyclerView.LayoutManager layoutManager) { + this.layoutManager = layoutManager; + } + + public void attachView(View view) { + layoutManager.attachView(view); + } + + public void detachView(View view) { + layoutManager.detachView(view); + } + + public void detachAndScrapView(View view, RecyclerView.Recycler recycler) { + layoutManager.detachAndScrapView(view, recycler); + } + + public void detachAndScrapAttachedViews(RecyclerView.Recycler recycler) { + layoutManager.detachAndScrapAttachedViews(recycler); + } + + public void recycleView(View view, RecyclerView.Recycler recycler) { + recycler.recycleView(view); + } + + public void removeAndRecycleAllViews(RecyclerView.Recycler recycler) { + layoutManager.removeAndRecycleAllViews(recycler); + } + + public int getChildCount() { + return layoutManager.getChildCount(); + } + + public int getItemCount() { + return layoutManager.getItemCount(); + } + + public View getMeasuredChildForAdapterPosition(int position, RecyclerView.Recycler recycler) { + View view = recycler.getViewForPosition(position); + layoutManager.addView(view); + layoutManager.measureChildWithMargins(view, 0, 0); + return view; + } + + public void layoutDecoratedWithMargins(View v, int left, int top, int right, int bottom) { + layoutManager.layoutDecoratedWithMargins(v, left, top, right, bottom); + } + + public View getChildAt(int index) { + return layoutManager.getChildAt(index); + } + + public int getPosition(View view) { + return layoutManager.getPosition(view); + } + + public int getMeasuredWidthWithMargin(View child) { + ViewGroup.MarginLayoutParams lp = (ViewGroup.MarginLayoutParams) child.getLayoutParams(); + return layoutManager.getDecoratedMeasuredWidth(child) + lp.leftMargin + lp.rightMargin; + } + + public int getMeasuredHeightWithMargin(View child) { + ViewGroup.MarginLayoutParams lp = (ViewGroup.MarginLayoutParams) child.getLayoutParams(); + return layoutManager.getDecoratedMeasuredHeight(child) + lp.topMargin + lp.bottomMargin; + } + + public int getWidth() { + return layoutManager.getWidth(); + } + + public int getHeight() { + return layoutManager.getHeight(); + } + + public void offsetChildrenHorizontal(int amount) { + layoutManager.offsetChildrenHorizontal(amount); + } + + public void offsetChildrenVertical(int amount) { + layoutManager.offsetChildrenVertical(amount); + } + + public void requestLayout() { + layoutManager.requestLayout(); + } + + public void startSmoothScroll(RecyclerView.SmoothScroller smoothScroller) { + layoutManager.startSmoothScroll(smoothScroller); + } + + public void removeAllViews() { + layoutManager.removeAllViews(); + } +} diff --git a/library/src/main/java/com/yarolegovich/discretescrollview/transform/Pivot.java b/library/src/main/java/com/yarolegovich/discretescrollview/transform/Pivot.java index a97f8ff..ff2ae5e 100644 --- a/library/src/main/java/com/yarolegovich/discretescrollview/transform/Pivot.java +++ b/library/src/main/java/com/yarolegovich/discretescrollview/transform/Pivot.java @@ -1,8 +1,9 @@ package com.yarolegovich.discretescrollview.transform; -import android.support.annotation.IntDef; import android.view.View; +import androidx.annotation.IntDef; + import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; diff --git a/library/src/main/java/com/yarolegovich/discretescrollview/transform/ScaleTransformer.java b/library/src/main/java/com/yarolegovich/discretescrollview/transform/ScaleTransformer.java index a42c313..2aa7069 100644 --- a/library/src/main/java/com/yarolegovich/discretescrollview/transform/ScaleTransformer.java +++ b/library/src/main/java/com/yarolegovich/discretescrollview/transform/ScaleTransformer.java @@ -1,8 +1,9 @@ package com.yarolegovich.discretescrollview.transform; -import android.support.annotation.FloatRange; import android.view.View; +import androidx.annotation.FloatRange; + /** * Created by yarolegovich on 03.03.2017. */ diff --git a/library/src/main/java/com/yarolegovich/discretescrollview/util/ScrollListenerAdapter.java b/library/src/main/java/com/yarolegovich/discretescrollview/util/ScrollListenerAdapter.java index f47d7df..11ad976 100644 --- a/library/src/main/java/com/yarolegovich/discretescrollview/util/ScrollListenerAdapter.java +++ b/library/src/main/java/com/yarolegovich/discretescrollview/util/ScrollListenerAdapter.java @@ -1,8 +1,9 @@ package com.yarolegovich.discretescrollview.util; -import android.support.annotation.NonNull; -import android.support.annotation.Nullable; -import android.support.v7.widget.RecyclerView; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.recyclerview.widget.RecyclerView; import com.yarolegovich.discretescrollview.DiscreteScrollView; @@ -37,7 +38,7 @@ public void onScroll(float scrollPosition, @Override public boolean equals(Object obj) { if (obj instanceof ScrollListenerAdapter) { - return adaptee.equals(((ScrollListenerAdapter) obj).adaptee); + return adaptee.equals(((ScrollListenerAdapter) obj).adaptee); } else { return super.equals(obj); } diff --git a/library/src/test/java/com/yarolegovich/discretescrollview/DiscreteScrollLayoutManagerTest.java b/library/src/test/java/com/yarolegovich/discretescrollview/DiscreteScrollLayoutManagerTest.java new file mode 100644 index 0000000..e11ba43 --- /dev/null +++ b/library/src/test/java/com/yarolegovich/discretescrollview/DiscreteScrollLayoutManagerTest.java @@ -0,0 +1,563 @@ +package com.yarolegovich.discretescrollview; + +import android.view.View; + +import androidx.recyclerview.widget.RecyclerView; + +import com.yarolegovich.discretescrollview.stub.StubRecyclerViewProxy; + +import org.junit.Before; +import org.junit.Test; +import org.robolectric.RuntimeEnvironment; + +import java.util.Arrays; +import java.util.List; + +import static com.yarolegovich.discretescrollview.DiscreteScrollLayoutManager.NO_POSITION; +import static com.yarolegovich.discretescrollview.DiscreteScrollLayoutManager.SCROLL_TO_SNAP_TO_ANOTHER_ITEM; +import static org.mockito.ArgumentMatchers.anyFloat; +import static org.mockito.ArgumentMatchers.nullable; +import static org.mockito.Matchers.any; +import static org.junit.Assert.*; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.*; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.spy; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import static org.mockito.hamcrest.MockitoHamcrest.argThat; + +/** + * Created by yarolegovich on 10/25/17. + */ +public abstract class DiscreteScrollLayoutManagerTest { + + private static final int RECYCLER_WIDTH = 400; + private static final int RECYCLER_HEIGHT = 600; + private static final int CHILD_WIDTH = 130; + private static final int CHILD_HEIGHT = 600; + private static final int ADAPTER_ITEM_COUNT = 10; + + private DiscreteScrollLayoutManager layoutManager; + private DiscreteScrollLayoutManager.ScrollStateListener mockScrollStateListener; + private StubRecyclerViewProxy stubRecyclerViewProxy; + private DSVOrientation.Helper stubOrientationHelper; + private RecyclerView.State stubState; + + @Before + public void setUp() { + stubState = mock(RecyclerView.State.class); + stubOrientationHelper = spy(getOrientationToTest().createHelper()); + mockScrollStateListener = mock(DiscreteScrollLayoutManager.ScrollStateListener.class); + + layoutManager = spy(new DiscreteScrollLayoutManager( + RuntimeEnvironment.application, + mockScrollStateListener, + getOrientationToTest())); + + stubRecyclerViewProxy = spy(new StubRecyclerViewProxy.Builder(layoutManager) + .withRecyclerDimensions(RECYCLER_WIDTH, RECYCLER_HEIGHT) + .withChildDimensions(CHILD_WIDTH, CHILD_HEIGHT) + .withAdapterItemCount(ADAPTER_ITEM_COUNT) + .create()); + + layoutManager.setRecyclerViewProxy(stubRecyclerViewProxy); + layoutManager.setOrientationHelper(stubOrientationHelper); + } + + protected abstract DSVOrientation getOrientationToTest(); + + @Test + public void onLayoutChildren_noItems_removesViewsAndResetsState() { + layoutManager.pendingScroll = 200; + layoutManager.scrolled = 1000; + layoutManager.pendingPosition = 1; + layoutManager.currentPosition = 2; + when(stubState.getItemCount()).thenReturn(0); + + layoutManager.onLayoutChildren(null, stubState); + + verify(stubRecyclerViewProxy).removeAndRecycleAllViews(nullable(RecyclerView.Recycler.class)); + assertThat(layoutManager.pendingScroll, is(0)); + assertThat(layoutManager.scrolled, is(0)); + assertThat(layoutManager.pendingPosition, is(NO_POSITION)); + assertThat(layoutManager.currentPosition, is(NO_POSITION)); + } + + @Test + public void onLayoutChildren_whenFirstOrEmptyLayout_childDimensionsAreInitialized() { + layoutManager.childHalfWidth = 0; + layoutManager.childHalfHeight = 0; + when(stubState.getItemCount()).thenReturn(ADAPTER_ITEM_COUNT); + + layoutManager.onLayoutChildren(null, stubState); + + assertThat(layoutManager.childHalfWidth, is(CHILD_WIDTH / 2)); + assertThat(layoutManager.childHalfHeight, is(CHILD_HEIGHT / 2)); + } + + @Test + public void onLayoutChildren_notFirstOrEmptyLayout_childDimensionsAreNotInitialized() { + stubRecyclerViewProxy.addChildren(5, 0); + + layoutManager.onLayoutChildren(null, stubState); + + assertThat(layoutManager.childHalfWidth, is(0)); + assertThat(layoutManager.childHalfHeight, is(0)); + } + + @Test + public void onLayoutChildren_multipleCallsInLayoutPhase_isFirstOrEmptyLayoutFlagNotCleared() { + when(stubState.getItemCount()).thenReturn(ADAPTER_ITEM_COUNT); + + layoutManager.onLayoutChildren(null, stubState); + stubRecyclerViewProxy.addChildren(5, 0); + layoutManager.onLayoutChildren(null, stubState); + + assertTrue(layoutManager.isFirstOrEmptyLayout); + } + + @Test + public void onLayoutCompleted_isFirstOrEmptyLayoutFlagSet_scrollStateListenerIsNotified() { + layoutManager.isFirstOrEmptyLayout = true; + + layoutManager.onLayoutCompleted(stubState); + + verify(mockScrollStateListener).onCurrentViewFirstLayout(); + } + + @Test + public void onLayoutCompleted_isFirstOrEmptyLayoutFlagSet_theFlagIsCleared() { + layoutManager.isFirstOrEmptyLayout = true; + + layoutManager.onLayoutCompleted(stubState); + + assertFalse(layoutManager.isFirstOrEmptyLayout); + } + + @Test + public void initChildDimensions_offscreenItemsNotSet_noExtraLayoutSpace() { + layoutManager.extraLayoutSpace = 1000; + + layoutManager.initChildDimensions(null); + + assertThat(layoutManager.extraLayoutSpace, is(0)); + } + + @Test + public void initChildDimensions_offscreenItemsSet_extraLayoutSpaceIsCalculated() { + layoutManager.extraLayoutSpace = 0; + layoutManager.setOffscreenItems(5); + + layoutManager.initChildDimensions(null); + + assertThat(layoutManager.extraLayoutSpace, is(not(0))); + } + + @Test + public void updateRecyclerDimensions_recyclerCenterIsInitialized() { + layoutManager.recyclerCenter.set(0, 0); + + layoutManager.updateRecyclerDimensions(stubState); + + assertThat(layoutManager.recyclerCenter.x, is(RECYCLER_WIDTH / 2)); + assertThat(layoutManager.recyclerCenter.y, is(RECYCLER_HEIGHT / 2)); + } + + @Test + public void cacheAndDetachAttachedViews_allRecyclerChildrenArePutToCache() { + final int childCount = 6; + stubRecyclerViewProxy.addChildren(6, 0); + + layoutManager.cacheAndDetachAttachedViews(); + + assertThat(layoutManager.detachedCache.size(), is(childCount)); + for (int i = 0; i < childCount; i++) { + int position = stubRecyclerViewProxy.getPosition(stubRecyclerViewProxy.getChildAt(i)); + assertNotNull(layoutManager.detachedCache.get(position)); + } + } + + @Test + public void cacheAndDetachAttachedViews_allRecyclerChildrenAreDetached() { + final int childCount = 5; + stubRecyclerViewProxy.addChildren(childCount, 0); + + layoutManager.cacheAndDetachAttachedViews(); + + for (int i = 0; i < childCount; i++) { + verify(stubRecyclerViewProxy).detachView(stubRecyclerViewProxy.getChildAt(i)); + } + } + + @Test + public void recycleDetachedViewsAndClearCache_cacheIsClearedAndViewsAreRecycled() { + List views = Arrays.asList(mock(View.class), mock(View.class), mock(View.class)); + for (int i = 0; i < views.size(); i++) layoutManager.detachedCache.put(i, views.get(i)); + + layoutManager.recycleDetachedViewsAndClearCache(null); + + assertThat(layoutManager.detachedCache.size(), is(0)); + verify(stubRecyclerViewProxy, times(views.size())) + .recycleView(argThat(isIn(views)), nullable(RecyclerView.Recycler.class)); + } + + @Test + public void onItemsAdded_afterCurrentPosition_currentPositionIsUnchanged() { + final int addedItems = 3; + final int initialCurrent = ADAPTER_ITEM_COUNT / 2; + layoutManager.currentPosition = initialCurrent; + stubRecyclerViewProxy.setAdapterItemCount(ADAPTER_ITEM_COUNT + addedItems); + + layoutManager.onItemsAdded(null, initialCurrent + 1, addedItems); + + assertThat(layoutManager.currentPosition, is(initialCurrent)); + } + + @Test + public void onItemsAdded_beforeCurrentPosition_currentIsShiftedByAmountOfAddedItems() { + final int addedItems = 3; + final int initialCurrent = ADAPTER_ITEM_COUNT / 2; + layoutManager.currentPosition = initialCurrent; + stubRecyclerViewProxy.setAdapterItemCount(ADAPTER_ITEM_COUNT + addedItems); + + layoutManager.onItemsAdded(null, initialCurrent - 1, addedItems); + + assertThat(layoutManager.currentPosition, is(initialCurrent + addedItems)); + } + + @Test + public void onItemsRemoved_afterCurrentPosition_currentIsUnchanged() { + final int initialCurrent = ADAPTER_ITEM_COUNT / 2; + final int removedItems = initialCurrent / 2; + layoutManager.currentPosition = initialCurrent; + stubRecyclerViewProxy.setAdapterItemCount(ADAPTER_ITEM_COUNT - removedItems); + + layoutManager.onItemsRemoved(null, initialCurrent + 1, removedItems); + + assertThat(layoutManager.currentPosition, is(initialCurrent)); + } + + @Test + public void onItemsRemoved_beforeCurrentPosition_currentIsShiftedByAmountRemoved() { + final int initialCurrent = ADAPTER_ITEM_COUNT / 2; + final int removedItems = initialCurrent / 2; + layoutManager.currentPosition = initialCurrent; + stubRecyclerViewProxy.setAdapterItemCount(ADAPTER_ITEM_COUNT - removedItems); + + layoutManager.onItemsRemoved(null, 0, removedItems); + + assertThat(layoutManager.currentPosition, is(initialCurrent - removedItems)); + } + + @Test + public void onItemsRemoved_rangeWhichContainsCurrent_currentIsReset() { + final int initialCurrent = ADAPTER_ITEM_COUNT / 2; + layoutManager.currentPosition = initialCurrent; + + layoutManager.onItemsRemoved(null, initialCurrent - 1, 3); + + assertThat(layoutManager.currentPosition, is(0)); + } + + @Test + public void onItemsChanged_removedItemWhichWasCurrent_currentRemainsInValidRange() { + layoutManager.currentPosition = ADAPTER_ITEM_COUNT - 1; + stubRecyclerViewProxy.setAdapterItemCount(ADAPTER_ITEM_COUNT - 3); + + layoutManager.onItemsChanged(null); + + assertThat(layoutManager.currentPosition, is(stubRecyclerViewProxy.getItemCount() - 1)); + } + + @Test + public void scrollBy_noChildren_noScrollPerformed() { + doReturn(0).when(layoutManager).getChildCount(); + + int scrolled = layoutManager.scrollBy(1000, null); + + assertThat(scrolled, is(0)); + } + + @Test + public void scrollBy_moreScrollRequestedThanCanPerform_scrollsByAllAvailableAmount() { + final int requested = 1000, maxAvailable = 333; + prepareStubsForScrollBy(maxAvailable, 3, false); + + int scrolled = layoutManager.scrollBy(requested, null); + + assertThat(scrolled, both(not(equalTo(requested))).and(is(equalTo(maxAvailable)))); + } + + @Test + public void scrollBy_offsetsChildrenByNegativeScrollDelta() { + final int requested = 1000; + prepareStubsForScrollBy(requested, 3, false); + + int scrolled = layoutManager.scrollBy(requested, null); + + verify(stubOrientationHelper).offsetChildren(-scrolled, stubRecyclerViewProxy); + } + + @Test + public void scrollBy_noNewViewBecameVisible_fillIsNotCalled() { + prepareStubsForScrollBy(1000, 10, false); + + layoutManager.scrollBy(200, null); + + verify(layoutManager, never()).fill(any(RecyclerView.Recycler.class)); + } + + @Test + public void scrollBy_newViewBecomesVisible_fillIsCalled() { + final int childCount = 5; + stubRecyclerViewProxy.addChildren(childCount, 0); + prepareStubsForScrollBy(1000, childCount, true); + + layoutManager.scrollBy(200, null); + + verify(layoutManager).fill(nullable(RecyclerView.Recycler.class)); + } + + @Test + public void scrollBy_hasPendingScroll_pendingScrollDecreasedByScrolledAmount() { + final int initialPendingScroll = 1000; + layoutManager.pendingScroll = initialPendingScroll; + prepareStubsForScrollBy(300, 3, false); + + int scrolled = layoutManager.scrollBy(200, null); + + assertThat(layoutManager.pendingScroll, is(initialPendingScroll - scrolled)); + } + + @Test + public void scrollBy_scrollIsAccumulated() { + int initialScrolled = layoutManager.scrolled; + prepareStubsForScrollBy(300, 3, false); + + int scrolled = layoutManager.scrollBy(100, null); + + assertThat(layoutManager.scrolled, is(initialScrolled + scrolled)); + } + + @Test + public void scrollBy_scrolledMoreThanZero_listenerIsNotifiedAboutScroll() { + prepareStubsForScrollBy(300, 3, false); + + int scrolled = layoutManager.scrollBy(200, null); + + assertThat(scrolled, is(greaterThan(0))); + verify(mockScrollStateListener).onScroll(anyFloat()); + } + + @Test + public void scrollBy_scrolledByZero_listenerIsNotNotifiedAboutScroll() { + prepareStubsForScrollBy(0, 2, false); + + int scrolled = layoutManager.scrollBy(300, null); + + assertThat(scrolled, is(0)); + verify(mockScrollStateListener, never()).onScroll(anyFloat()); + } + + @Test + public void scrollBy_triesToScrollToTheItemBeforeFirst_onBoundReachedIsTrue() { + layoutManager.currentPosition = 0; + stubRecyclerViewProxy.addChildren(5, 0); + + layoutManager.scrollBy(-100, null); + + verify(mockScrollStateListener).onIsBoundReachedFlagChange(true); + } + + @Test + public void scrollBy_triesToScrollToTheItemAfterLast_onBoundReachedIsTrue() { + layoutManager.currentPosition = ADAPTER_ITEM_COUNT - 1; + stubRecyclerViewProxy.addChildren(5, ADAPTER_ITEM_COUNT - 5); + + layoutManager.scrollBy(100, null); + + verify(mockScrollStateListener).onIsBoundReachedFlagChange(true); + } + + @Test + public void scrollBy_scrollsToAllowedElement_onBoundReachedIsFalse() { + layoutManager.currentPosition = ADAPTER_ITEM_COUNT / 2; + stubRecyclerViewProxy.addChildren(5, 0); + + layoutManager.scrollBy(100, null); + + verify(mockScrollStateListener).onIsBoundReachedFlagChange(false); + } + + @Test + public void onScrollStateChanged_dragStartedWhenWasIdle_listenerNotifiedAboutScrollStart() { + layoutManager.currentScrollState = RecyclerView.SCROLL_STATE_IDLE; + + layoutManager.onScrollStateChanged(RecyclerView.SCROLL_STATE_DRAGGING); + + verify(mockScrollStateListener).onScrollStart(); + } + + @Test + public void onScrollStateChanged_settlingStartedWhenWasIdle_listenerNotifiedAboutScrollStart() { + layoutManager.currentScrollState = RecyclerView.SCROLL_STATE_IDLE; + + layoutManager.onScrollStateChanged(RecyclerView.SCROLL_STATE_SETTLING); + + verify(mockScrollStateListener).onScrollStart(); + } + + @Test + public void onScrollStateChanged_newState_layoutManagerUpdatesItsState() { + final int newState = RecyclerView.SCROLL_STATE_DRAGGING; + layoutManager.currentScrollState = RecyclerView.SCROLL_STATE_IDLE; + assertThat(layoutManager.currentScrollState, is(not(newState))); + + layoutManager.onScrollStateChanged(newState); + + assertThat(layoutManager.currentScrollState, is(newState)); + } + + @Test + public void onScrollStateChanged_scrolledEnoughToChangeCurrent_listenerNotifiedAboutScrollEnd() { + layoutManager.currentScrollState = RecyclerView.SCROLL_STATE_DRAGGING; + layoutManager.scrolled = layoutManager.scrollToChangeCurrent; + + layoutManager.onScrollStateChanged(RecyclerView.SCROLL_STATE_IDLE); + + verify(mockScrollStateListener).onScrollEnd(); + } + + @Test + public void onScrollStateChanged_scrolledNotEnoughToChangeCurrent_listenerNotNotifiedAboutScrollEnd() { + layoutManager.currentScrollState = RecyclerView.SCROLL_STATE_DRAGGING; + layoutManager.scrollToChangeCurrent = stubOrientationHelper.getDistanceToChangeCurrent(CHILD_WIDTH, CHILD_HEIGHT); + layoutManager.scrolled = layoutManager.scrollToChangeCurrent / 2; + + layoutManager.onScrollStateChanged(RecyclerView.SCROLL_STATE_IDLE); + + verify(mockScrollStateListener, never()).onScrollEnd(); + } + + @Test + public void onScrollStateChanged_draggedLessThanScrollToSnapToAnotherItem_settlesToCurrentPosition() { + layoutManager.currentScrollState = RecyclerView.SCROLL_STATE_DRAGGING; + layoutManager.currentPosition = ADAPTER_ITEM_COUNT / 2; + layoutManager.scrollToChangeCurrent = stubOrientationHelper.getDistanceToChangeCurrent(CHILD_WIDTH, CHILD_HEIGHT); + layoutManager.pendingScroll = 0; + layoutManager.scrolled = (int) (layoutManager.scrollToChangeCurrent * (SCROLL_TO_SNAP_TO_ANOTHER_ITEM - 0.01f)); + + layoutManager.onScrollStateChanged(RecyclerView.SCROLL_STATE_IDLE); + + assertThat(layoutManager.pendingScroll, is(-layoutManager.scrolled)); + verify(stubRecyclerViewProxy).startSmoothScroll(any(RecyclerView.SmoothScroller.class)); + } + + @Test + public void onScrollStateChanged_draggedMoreThanOrScrollToSnapToAnotherItem_settlesToClosestItem() { + layoutManager.currentScrollState = RecyclerView.SCROLL_STATE_DRAGGING; + layoutManager.currentPosition = ADAPTER_ITEM_COUNT / 2; + layoutManager.scrollToChangeCurrent = stubOrientationHelper.getDistanceToChangeCurrent(CHILD_WIDTH, CHILD_HEIGHT); + layoutManager.pendingScroll = 0; + layoutManager.scrolled = (int) (layoutManager.scrollToChangeCurrent * SCROLL_TO_SNAP_TO_ANOTHER_ITEM); + int scrollLeftToAnotherItem = layoutManager.scrollToChangeCurrent - layoutManager.scrolled; + + layoutManager.onScrollStateChanged(RecyclerView.SCROLL_STATE_IDLE); + + assertThat(layoutManager.pendingScroll, is(scrollLeftToAnotherItem)); + verify(stubRecyclerViewProxy).startSmoothScroll(any(RecyclerView.SmoothScroller.class)); + } + + @Test + public void onScrollStateChanged_whenSettlingDragIsStarted_settlingStops() { + layoutManager.currentScrollState = RecyclerView.SCROLL_STATE_SETTLING; + layoutManager.pendingScroll = 1000; + + layoutManager.onScrollStateChanged(RecyclerView.SCROLL_STATE_DRAGGING); + + assertThat(layoutManager.pendingScroll, is(0)); + } + + @Test + public void onScrollStateChanged_whenSettlingDragIsStarted_closestPositionBecomesCurrent() { + final int initialPosition = 5; + layoutManager.currentScrollState = RecyclerView.SCROLL_STATE_SETTLING; + layoutManager.scrollToChangeCurrent = stubOrientationHelper.getDistanceToChangeCurrent(CHILD_WIDTH, CHILD_HEIGHT); + final int scrolled = (int) (layoutManager.scrollToChangeCurrent * SCROLL_TO_SNAP_TO_ANOTHER_ITEM); + layoutManager.scrolled = scrolled; + layoutManager.currentPosition = initialPosition; + + layoutManager.onScrollStateChanged(RecyclerView.SCROLL_STATE_DRAGGING); + + assertThat(layoutManager.currentPosition, is(initialPosition + 1)); + assertThat(layoutManager.scrolled, is(scrolled - layoutManager.scrollToChangeCurrent)); + } + + @Test + public void onFling_velocitiesWithDifferentSignsOnDifferentAxis_correctFlingDirection() { + final int velocityX = 100, velocityY = -100; + final int velocityToUse = stubOrientationHelper.getFlingVelocity(velocityX, velocityY); + Direction direction = Direction.fromDelta(velocityToUse); + layoutManager.currentPosition = ADAPTER_ITEM_COUNT / 2; + layoutManager.scrollToChangeCurrent = stubOrientationHelper.getDistanceToChangeCurrent(CHILD_WIDTH, CHILD_HEIGHT); + layoutManager.pendingScroll = 0; + + layoutManager.onFling(velocityX, velocityY); + + assertThat(layoutManager.pendingScroll, is(direction.applyTo(layoutManager.scrollToChangeCurrent))); + } + + @Test + public void onFling_toTheOppositeToScrollDirection_returnsToPosition() { + layoutManager.scrollToChangeCurrent = stubOrientationHelper.getDistanceToChangeCurrent(CHILD_WIDTH, CHILD_HEIGHT); + int scrolled = layoutManager.scrollToChangeCurrent / 2; + layoutManager.pendingScroll = 0; + layoutManager.scrolled = scrolled; + + layoutManager.onFling(-scrolled, -scrolled); + + assertThat(layoutManager.pendingScroll, is(-scrolled)); + } + + @Test + public void onFling_toTheSameDirectionAsScrolled_changesPosition() { + layoutManager.scrollToChangeCurrent = stubOrientationHelper.getDistanceToChangeCurrent(CHILD_WIDTH, CHILD_HEIGHT); + int scrolled = layoutManager.scrollToChangeCurrent / 3; + int leftToScroll = layoutManager.scrollToChangeCurrent - scrolled; + layoutManager.pendingScroll = 0; + layoutManager.scrolled = scrolled; + + layoutManager.onFling(scrolled, scrolled); + + assertThat(layoutManager.pendingScroll, is(leftToScroll)); + } + + @Test + public void onFling_toItemBeforeTheFirst_isImpossible() { + layoutManager.currentPosition = 0; + + layoutManager.onFling(-100, -100); + + assertThat(layoutManager.pendingScroll, is(0)); + } + + @Test + public void onFling_toItemAfterTheLast_isImpossible() { + layoutManager.currentPosition = ADAPTER_ITEM_COUNT - 1; + + layoutManager.onFling(100, 100); + + assertThat(layoutManager.pendingScroll, is(0)); + } + + private void prepareStubsForScrollBy(int allowedScroll, int childCount, boolean hasNewBecomeVisible) { + doReturn(allowedScroll).when(layoutManager).calculateAllowedScrollIn(any(Direction.class)); + stubRecyclerViewProxy.addChildren(childCount, 0); + doReturn(hasNewBecomeVisible).when(stubOrientationHelper).hasNewBecomeVisible(any(DiscreteScrollLayoutManager.class)); + } + +} diff --git a/library/src/test/java/com/yarolegovich/discretescrollview/HorizontalDiscreteScrollLayoutManagerTest.java b/library/src/test/java/com/yarolegovich/discretescrollview/HorizontalDiscreteScrollLayoutManagerTest.java new file mode 100644 index 0000000..971dbcd --- /dev/null +++ b/library/src/test/java/com/yarolegovich/discretescrollview/HorizontalDiscreteScrollLayoutManagerTest.java @@ -0,0 +1,19 @@ +package com.yarolegovich.discretescrollview; + +import org.junit.runner.RunWith; +import org.robolectric.RobolectricTestRunner; +import org.robolectric.annotation.Config; + +/** + * Created by yarolegovich on 10/28/17. + */ +@RunWith(RobolectricTestRunner.class) +@Config(manifest = Config.NONE) +public class HorizontalDiscreteScrollLayoutManagerTest extends DiscreteScrollLayoutManagerTest { + + @Override + protected DSVOrientation getOrientationToTest() { + return DSVOrientation.HORIZONTAL; + } + +} diff --git a/library/src/test/java/com/yarolegovich/discretescrollview/VerticalDiscreteScrollLayoutManagerTest.java b/library/src/test/java/com/yarolegovich/discretescrollview/VerticalDiscreteScrollLayoutManagerTest.java new file mode 100644 index 0000000..070382b --- /dev/null +++ b/library/src/test/java/com/yarolegovich/discretescrollview/VerticalDiscreteScrollLayoutManagerTest.java @@ -0,0 +1,19 @@ +package com.yarolegovich.discretescrollview; + +import org.junit.runner.RunWith; +import org.robolectric.RobolectricTestRunner; +import org.robolectric.annotation.Config; + +/** + * Created by yarolegovich on 10/28/17. + */ +@RunWith(RobolectricTestRunner.class) +@Config(manifest = Config.NONE) +public class VerticalDiscreteScrollLayoutManagerTest extends DiscreteScrollLayoutManagerTest { + + @Override + protected DSVOrientation getOrientationToTest() { + return DSVOrientation.VERTICAL; + } + +} diff --git a/library/src/test/java/com/yarolegovich/discretescrollview/stub/StubRecyclerViewProxy.java b/library/src/test/java/com/yarolegovich/discretescrollview/stub/StubRecyclerViewProxy.java new file mode 100644 index 0000000..eb72168 --- /dev/null +++ b/library/src/test/java/com/yarolegovich/discretescrollview/stub/StubRecyclerViewProxy.java @@ -0,0 +1,195 @@ +package com.yarolegovich.discretescrollview.stub; + +import android.view.View; + +import androidx.annotation.NonNull; +import androidx.recyclerview.widget.RecyclerView; + +import com.yarolegovich.discretescrollview.RecyclerViewProxy; + +import java.util.ArrayList; +import java.util.List; + +import static org.mockito.Mockito.mock; + +/** + * Created by yarolegovich on 10/28/17. + */ + +public class StubRecyclerViewProxy extends RecyclerViewProxy { + + private int width, height; + private int childWidth, childHeight; + private List children; + private int adapterItemCount; + + public StubRecyclerViewProxy(@NonNull RecyclerView.LayoutManager layoutManager) { + super(layoutManager); + children = new ArrayList<>(); + } + + @Override + public void removeAndRecycleAllViews(RecyclerView.Recycler recycler) { + for (StubChildInfo childInfo : children) { + recycleView(childInfo.view, recycler); + } + removeAllViews(); + } + + @Override + public int getChildCount() { + return children.size(); + } + + @Override + public int getItemCount() { + return adapterItemCount; + } + + @Override + public View getMeasuredChildForAdapterPosition(int position, RecyclerView.Recycler recycler) { + if (position < adapterItemCount) { + return new StubChildInfo(0, position).view; + } + throw new IndexOutOfBoundsException(); + } + + @Override + public View getChildAt(int index) { + return children.get(index).view; + } + + @Override + public int getPosition(View view) { + for (StubChildInfo info : children) { + if (info.view == view) return info.adapterPosition; + } + throw new IllegalArgumentException(); + } + + @Override + public int getMeasuredWidthWithMargin(View child) { + return childWidth; + } + + @Override + public int getMeasuredHeightWithMargin(View child) { + return childHeight; + } + + @Override + public int getWidth() { + return width; + } + + @Override + public int getHeight() { + return height; + } + + @Override + public void removeAllViews() { + children.clear(); + } + + @Override + public void offsetChildrenHorizontal(int amount) { + //NOP + } + + @Override + public void offsetChildrenVertical(int amount) { + //NOP + } + + @Override + public void attachView(View view) { + //NOP + } + + @Override + public void detachView(View view) { + //NOP + } + + @Override + public void detachAndScrapView(View view, RecyclerView.Recycler recycler) { + //NOP + } + + @Override + public void detachAndScrapAttachedViews(RecyclerView.Recycler recycler) { + //NOP + } + + @Override + public void recycleView(View view, RecyclerView.Recycler recycler) { + //NOP + } + + @Override + public void layoutDecoratedWithMargins(View v, int left, int top, int right, int bottom) { + //NOP + } + + @Override + public void requestLayout() { + //NOP + } + + @Override + public void startSmoothScroll(RecyclerView.SmoothScroller smoothScroller) { + //NOP + } + + public void addChildren(int childCount, int firstChildAdapterPosition) { + for (int i = 0; i < childCount; i++) { + children.add(new StubChildInfo(i, firstChildAdapterPosition + i)); + } + } + + public void setAdapterItemCount(int adapterItemCount) { + this.adapterItemCount = adapterItemCount; + } + + private static class StubChildInfo { + public final View view; + public final int recyclerChildIndex; + public final int adapterPosition; + + private StubChildInfo(int recyclerChildIndex, int adapterPosition) { + this.view = mock(View.class); + this.recyclerChildIndex = recyclerChildIndex; + this.adapterPosition = adapterPosition; + } + } + + public static class Builder { + StubRecyclerViewProxy target; + + public Builder(RecyclerView.LayoutManager lm) { + target = new StubRecyclerViewProxy(lm); + } + + public Builder withAdapterItemCount(int count) { + target.adapterItemCount = count; + return this; + } + + public Builder withRecyclerDimensions(int width, int height) { + target.width = width; + target.height = height; + return this; + } + + public Builder withChildDimensions(int width, int height) { + target.childWidth = width; + target.childHeight = height; + return this; + } + + public StubRecyclerViewProxy create() { + return target; + } + } +} diff --git a/release-bintray.gradle b/release-bintray.gradle new file mode 100644 index 0000000..ec2206a --- /dev/null +++ b/release-bintray.gradle @@ -0,0 +1,60 @@ +apply plugin: 'maven-publish' +apply plugin: 'com.jfrog.bintray' + +def upload = [ + user : 'yarolegovich', + artifactId : 'discrete-scrollview', + userOrg : 'yarolegovich', + repository : 'maven', + groupId : 'com.yarolegovich', + uploadName : 'DiscreteScrollView', + description: 'A scrollable list of items that centers the current element and provides easy-to-use APIs for cool item animations.', + version : '1.5.1', + licences : ['Apache-2.0'] +] + +task androidSourcesJar(type: Jar) { + archiveClassifier.set('sources') + from android.sourceSets.main.java.srcDirs +} + +version upload.version + +afterEvaluate { + + publishing { + publications { + LibRelease(MavenPublication) { + from components.release + + artifact androidSourcesJar + + artifactId upload.artifactId + groupId upload.groupId + version upload.version + } + } + } + + Properties localProps = new Properties() + localProps.load(project.rootProject.file('local.properties').newDataInputStream()) + + bintray { + user = upload.user + key = localProps.getProperty('bintray.api_key') + publications = ['LibRelease'] + configurations = ['archives'] + pkg { + name = upload.uploadName + repo = upload.repository + userOrg = upload.userOrg + licenses = upload.licences + publish = true + dryRun = false + version { + name = upload.version + desc = upload.description + } + } + } +} \ No newline at end of file diff --git a/sample/build.gradle b/sample/build.gradle index 9704d96..f684808 100644 --- a/sample/build.gradle +++ b/sample/build.gradle @@ -1,12 +1,13 @@ apply plugin: 'com.android.application' android { - compileSdkVersion rootProject.ext.compileSdkVersion - buildToolsVersion rootProject.ext.buildToolsVersion + compileSdkVersion rootProject.compileSdkVersion + buildToolsVersion rootProject.buildToolsVersion + defaultConfig { applicationId "com.yarolegovich.discretescrollview.sample" minSdkVersion 19 - targetSdkVersion rootProject.ext.targetSdkVersion + targetSdkVersion rootProject.targetSdkVersion versionCode 4 versionName "1.0" } @@ -20,15 +21,10 @@ android { } dependencies { - compile fileTree(dir: 'libs', include: ['*.jar']) - - compile "com.android.support:appcompat-v7:$supportLibVersion" - compile "com.android.support:cardview-v7:$supportLibVersion" - compile "com.android.support:design:$supportLibVersion" - - compile 'com.github.bumptech.glide:glide:3.7.0' - - compile 'com.yarolegovich:mp:1.0.5' + implementation deps.designSupport + implementation deps.annotations + implementation deps.glide + implementation deps.materialPrefs - compile project(':library') + implementation project(':library') } diff --git a/sample/src/main/java/com/yarolegovich/discretescrollview/sample/DiscreteScrollViewOptions.java b/sample/src/main/java/com/yarolegovich/discretescrollview/sample/DiscreteScrollViewOptions.java index 20c56a2..6e6f894 100644 --- a/sample/src/main/java/com/yarolegovich/discretescrollview/sample/DiscreteScrollViewOptions.java +++ b/sample/src/main/java/com/yarolegovich/discretescrollview/sample/DiscreteScrollViewOptions.java @@ -3,14 +3,15 @@ import android.content.Context; import android.content.DialogInterface; import android.content.SharedPreferences; -import android.support.design.widget.BottomSheetDialog; -import android.support.v7.preference.PreferenceManager; -import android.support.v7.widget.PopupMenu; -import android.support.v7.widget.RecyclerView; +import android.preference.PreferenceManager; import android.view.Menu; import android.view.MenuItem; import android.view.View; +import androidx.appcompat.widget.PopupMenu; +import androidx.recyclerview.widget.RecyclerView; + +import com.google.android.material.bottomsheet.BottomSheetDialog; import com.yarolegovich.discretescrollview.DiscreteScrollView; import com.yarolegovich.discretescrollview.InfiniteScrollAdapter; @@ -45,22 +46,25 @@ public void onDismiss(DialogInterface dialog) { defaultPrefs().unregisterOnSharedPreferenceChangeListener(timeChangeListener); } }); - bsd.findViewById(R.id.dialog_btn_dismiss).setOnClickListener(new View.OnClickListener() { - @Override - public void onClick(View v) { - bsd.dismiss(); - } - }); + View dismissBtn = bsd.findViewById(R.id.dialog_btn_dismiss); + if (dismissBtn != null) { + dismissBtn.setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View v) { + bsd.dismiss(); + } + }); + } bsd.show(); } public static void smoothScrollToUserSelectedPosition(final DiscreteScrollView scrollView, View anchor) { PopupMenu popupMenu = new PopupMenu(scrollView.getContext(), anchor); Menu menu = popupMenu.getMenu(); - final RecyclerView.Adapter adapter = scrollView.getAdapter(); + final RecyclerView.Adapter adapter = scrollView.getAdapter(); int itemCount = (adapter instanceof InfiniteScrollAdapter) ? - ((InfiniteScrollAdapter) adapter).getRealItemCount() : - adapter.getItemCount(); + ((InfiniteScrollAdapter) adapter).getRealItemCount() : + (adapter != null ? adapter.getItemCount() : 0); for (int i = 0; i < itemCount; i++) { menu.add(String.valueOf(i + 1)); } @@ -69,7 +73,7 @@ public static void smoothScrollToUserSelectedPosition(final DiscreteScrollView s public boolean onMenuItemClick(MenuItem item) { int destination = Integer.parseInt(String.valueOf(item.getTitle())) - 1; if (adapter instanceof InfiniteScrollAdapter) { - destination = ((InfiniteScrollAdapter) adapter).getClosestPosition(destination); + destination = ((InfiniteScrollAdapter) adapter).getClosestPosition(destination); } scrollView.smoothScrollToPosition(destination); return true; @@ -82,6 +86,7 @@ public static int getTransitionTime() { return defaultPrefs().getInt(instance.KEY_TRANSITION_TIME, 150); } + @SuppressWarnings("deprecation") private static SharedPreferences defaultPrefs() { return PreferenceManager.getDefaultSharedPreferences(App.getInstance()); } diff --git a/sample/src/main/java/com/yarolegovich/discretescrollview/sample/MainActivity.java b/sample/src/main/java/com/yarolegovich/discretescrollview/sample/MainActivity.java index 262a68e..b025f10 100644 --- a/sample/src/main/java/com/yarolegovich/discretescrollview/sample/MainActivity.java +++ b/sample/src/main/java/com/yarolegovich/discretescrollview/sample/MainActivity.java @@ -4,13 +4,14 @@ import android.content.Intent; import android.net.Uri; import android.os.Bundle; -import android.support.design.widget.Snackbar; -import android.support.v7.app.AppCompatActivity; -import android.support.v7.widget.Toolbar; import android.view.Menu; import android.view.MenuItem; import android.view.View; +import androidx.appcompat.app.AppCompatActivity; +import androidx.appcompat.widget.Toolbar; + +import com.google.android.material.snackbar.Snackbar; import com.yarolegovich.discretescrollview.sample.gallery.GalleryActivity; import com.yarolegovich.discretescrollview.sample.shop.ShopActivity; import com.yarolegovich.discretescrollview.sample.weather.WeatherActivity; @@ -32,7 +33,7 @@ protected void onCreate(Bundle savedInstanceState) { root = findViewById(R.id.screen); - Toolbar toolbar = (Toolbar) findViewById(R.id.toolbar); + Toolbar toolbar = findViewById(R.id.toolbar); setSupportActionBar(toolbar); findViewById(R.id.preview_shop).setOnClickListener(this); @@ -52,10 +53,9 @@ public boolean onCreateOptionsMenu(Menu menu) { @Override public boolean onOptionsItemSelected(MenuItem item) { - switch (item.getItemId()) { - case R.id.mi_github: - open(URL_APP_REPO); - return true; + if (item.getItemId() == R.id.mi_github) { + open(URL_APP_REPO); + return true; } return super.onOptionsItemSelected(item); } diff --git a/sample/src/main/java/com/yarolegovich/discretescrollview/sample/gallery/GalleryActivity.java b/sample/src/main/java/com/yarolegovich/discretescrollview/sample/gallery/GalleryActivity.java index 4decd15..1256838 100644 --- a/sample/src/main/java/com/yarolegovich/discretescrollview/sample/gallery/GalleryActivity.java +++ b/sample/src/main/java/com/yarolegovich/discretescrollview/sample/gallery/GalleryActivity.java @@ -2,13 +2,13 @@ import android.animation.ArgbEvaluator; import android.os.Bundle; -import android.support.annotation.NonNull; -import android.support.annotation.Nullable; -import android.support.design.widget.Snackbar; -import android.support.v4.content.ContextCompat; -import android.support.v7.app.AppCompatActivity; import android.view.View; +import androidx.annotation.Nullable; +import androidx.appcompat.app.AppCompatActivity; +import androidx.core.content.ContextCompat; + +import com.google.android.material.snackbar.Snackbar; import com.yarolegovich.discretescrollview.DiscreteScrollView; import com.yarolegovich.discretescrollview.sample.R; @@ -34,7 +34,7 @@ protected void onCreate(Bundle savedInstanceState) { Gallery gallery = Gallery.get(); List data = gallery.getData(); - DiscreteScrollView itemPicker = (DiscreteScrollView) findViewById(R.id.item_picker); + DiscreteScrollView itemPicker = findViewById(R.id.item_picker); itemPicker.setAdapter(new GalleryAdapter(data)); itemPicker.addScrollListener(this); itemPicker.addOnItemChangedListener(this); diff --git a/sample/src/main/java/com/yarolegovich/discretescrollview/sample/gallery/GalleryAdapter.java b/sample/src/main/java/com/yarolegovich/discretescrollview/sample/gallery/GalleryAdapter.java index a502361..01f17fa 100644 --- a/sample/src/main/java/com/yarolegovich/discretescrollview/sample/gallery/GalleryAdapter.java +++ b/sample/src/main/java/com/yarolegovich/discretescrollview/sample/gallery/GalleryAdapter.java @@ -2,13 +2,15 @@ import android.app.Activity; import android.graphics.Point; -import android.support.annotation.ColorInt; -import android.support.v7.widget.RecyclerView; import android.view.LayoutInflater; import android.view.View; import android.view.ViewGroup; import android.widget.ImageView; +import androidx.annotation.ColorInt; +import androidx.annotation.NonNull; +import androidx.recyclerview.widget.RecyclerView; + import com.bumptech.glide.Glide; import com.yarolegovich.discretescrollview.sample.R; @@ -28,7 +30,7 @@ public GalleryAdapter(List data) { } @Override - public void onAttachedToRecyclerView(RecyclerView recyclerView) { + public void onAttachedToRecyclerView(@NonNull RecyclerView recyclerView) { super.onAttachedToRecyclerView(recyclerView); Activity context = (Activity) recyclerView.getContext(); Point windowDimensions = new Point(); @@ -36,6 +38,7 @@ public void onAttachedToRecyclerView(RecyclerView recyclerView) { itemHeight = Math.round(windowDimensions.y * 0.6f); } + @NonNull @Override public ViewHolder onCreateViewHolder(ViewGroup parent, int viewType) { LayoutInflater inflater = LayoutInflater.from(parent.getContext()); @@ -66,7 +69,7 @@ static class ViewHolder extends RecyclerView.ViewHolder { public ViewHolder(View itemView) { super(itemView); - image = (ImageView) itemView.findViewById(R.id.image); + image = itemView.findViewById(R.id.image); overlay = itemView.findViewById(R.id.overlay); } diff --git a/sample/src/main/java/com/yarolegovich/discretescrollview/sample/shop/ShopActivity.java b/sample/src/main/java/com/yarolegovich/discretescrollview/sample/shop/ShopActivity.java index 54c0770..1aa6392 100644 --- a/sample/src/main/java/com/yarolegovich/discretescrollview/sample/shop/ShopActivity.java +++ b/sample/src/main/java/com/yarolegovich/discretescrollview/sample/shop/ShopActivity.java @@ -1,19 +1,18 @@ package com.yarolegovich.discretescrollview.sample.shop; import android.os.Bundle; -import android.support.annotation.NonNull; -import android.support.annotation.Nullable; -import android.support.design.widget.Snackbar; -import android.support.v4.content.ContextCompat; -import android.support.v7.app.AppCompatActivity; -import android.support.v7.widget.RecyclerView; import android.view.View; import android.widget.ImageView; import android.widget.TextView; +import androidx.annotation.Nullable; +import androidx.appcompat.app.AppCompatActivity; +import androidx.core.content.ContextCompat; + +import com.google.android.material.snackbar.Snackbar; +import com.yarolegovich.discretescrollview.DSVOrientation; import com.yarolegovich.discretescrollview.DiscreteScrollView; import com.yarolegovich.discretescrollview.InfiniteScrollAdapter; -import com.yarolegovich.discretescrollview.Orientation; import com.yarolegovich.discretescrollview.sample.DiscreteScrollViewOptions; import com.yarolegovich.discretescrollview.sample.R; import com.yarolegovich.discretescrollview.transform.ScaleTransformer; @@ -24,7 +23,7 @@ * Created by yarolegovich on 07.03.2017. */ -public class ShopActivity extends AppCompatActivity implements DiscreteScrollView.OnItemChangedListener, +public class ShopActivity extends AppCompatActivity implements DiscreteScrollView.OnItemChangedListener, View.OnClickListener { private List data; @@ -34,21 +33,21 @@ public class ShopActivity extends AppCompatActivity implements DiscreteScrollVie private TextView currentItemPrice; private ImageView rateItemButton; private DiscreteScrollView itemPicker; - private InfiniteScrollAdapter infiniteAdapter; + private InfiniteScrollAdapter infiniteAdapter; @Override protected void onCreate(@Nullable Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.activity_shop); - currentItemName = (TextView) findViewById(R.id.item_name); - currentItemPrice = (TextView) findViewById(R.id.item_price); - rateItemButton = (ImageView) findViewById(R.id.item_btn_rate); + currentItemName = findViewById(R.id.item_name); + currentItemPrice = findViewById(R.id.item_price); + rateItemButton = findViewById(R.id.item_btn_rate); shop = Shop.get(); data = shop.getData(); - itemPicker = (DiscreteScrollView) findViewById(R.id.item_picker); - itemPicker.setOrientation(Orientation.HORIZONTAL); + itemPicker = findViewById(R.id.item_picker); + itemPicker.setOrientation(DSVOrientation.HORIZONTAL); itemPicker.addOnItemChangedListener(this); infiniteAdapter = InfiniteScrollAdapter.wrap(new ShopAdapter(data)); itemPicker.setAdapter(infiniteAdapter); @@ -109,8 +108,8 @@ private void changeRateButtonState(Item item) { } @Override - public void onCurrentItemChanged(@Nullable RecyclerView.ViewHolder viewHolder, int position) { - int positionInDataSet = infiniteAdapter.getRealPosition(position); + public void onCurrentItemChanged(@Nullable ShopAdapter.ViewHolder viewHolder, int adapterPosition) { + int positionInDataSet = infiniteAdapter.getRealPosition(adapterPosition); onItemChanged(data.get(positionInDataSet)); } diff --git a/sample/src/main/java/com/yarolegovich/discretescrollview/sample/shop/ShopAdapter.java b/sample/src/main/java/com/yarolegovich/discretescrollview/sample/shop/ShopAdapter.java index 21a3de1..0beee57 100644 --- a/sample/src/main/java/com/yarolegovich/discretescrollview/sample/shop/ShopAdapter.java +++ b/sample/src/main/java/com/yarolegovich/discretescrollview/sample/shop/ShopAdapter.java @@ -1,11 +1,13 @@ package com.yarolegovich.discretescrollview.sample.shop; -import android.support.v7.widget.RecyclerView; import android.view.LayoutInflater; import android.view.View; import android.view.ViewGroup; import android.widget.ImageView; +import androidx.annotation.NonNull; +import androidx.recyclerview.widget.RecyclerView; + import com.bumptech.glide.Glide; import com.yarolegovich.discretescrollview.sample.R; @@ -23,6 +25,7 @@ public ShopAdapter(List data) { this.data = data; } + @NonNull @Override public ViewHolder onCreateViewHolder(ViewGroup parent, int viewType) { LayoutInflater inflater = LayoutInflater.from(parent.getContext()); @@ -42,13 +45,13 @@ public int getItemCount() { return data.size(); } - class ViewHolder extends RecyclerView.ViewHolder { + static class ViewHolder extends RecyclerView.ViewHolder { private ImageView image; public ViewHolder(View itemView) { super(itemView); - image = (ImageView) itemView.findViewById(R.id.image); + image = itemView.findViewById(R.id.image); } } } diff --git a/sample/src/main/java/com/yarolegovich/discretescrollview/sample/weather/ForecastAdapter.java b/sample/src/main/java/com/yarolegovich/discretescrollview/sample/weather/ForecastAdapter.java index 396b3e5..8f79665 100644 --- a/sample/src/main/java/com/yarolegovich/discretescrollview/sample/weather/ForecastAdapter.java +++ b/sample/src/main/java/com/yarolegovich/discretescrollview/sample/weather/ForecastAdapter.java @@ -1,16 +1,21 @@ package com.yarolegovich.discretescrollview.sample.weather; import android.graphics.Color; -import android.support.v4.content.ContextCompat; -import android.support.v7.widget.RecyclerView; +import android.graphics.drawable.Drawable; import android.view.LayoutInflater; import android.view.View; import android.view.ViewGroup; import android.widget.ImageView; import android.widget.TextView; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.core.content.ContextCompat; +import androidx.recyclerview.widget.RecyclerView; + import com.bumptech.glide.Glide; -import com.bumptech.glide.load.resource.drawable.GlideDrawable; +import com.bumptech.glide.load.DataSource; +import com.bumptech.glide.load.engine.GlideException; import com.bumptech.glide.request.RequestListener; import com.bumptech.glide.request.target.Target; import com.yarolegovich.discretescrollview.sample.R; @@ -31,11 +36,12 @@ public ForecastAdapter(List data) { } @Override - public void onAttachedToRecyclerView(RecyclerView recyclerView) { + public void onAttachedToRecyclerView(@NonNull RecyclerView recyclerView) { super.onAttachedToRecyclerView(recyclerView); parentRecycler = recyclerView; } + @NonNull @Override public ViewHolder onCreateViewHolder(ViewGroup parent, int viewType) { LayoutInflater inflater = LayoutInflater.from(parent.getContext()); @@ -66,8 +72,8 @@ class ViewHolder extends RecyclerView.ViewHolder implements View.OnClickListener public ViewHolder(View itemView) { super(itemView); - imageView = (ImageView) itemView.findViewById(R.id.city_image); - textView = (TextView) itemView.findViewById(R.id.city_name); + imageView = itemView.findViewById(R.id.city_image); + textView = itemView.findViewById(R.id.city_name); itemView.findViewById(R.id.container).setOnClickListener(this); } @@ -103,7 +109,7 @@ public void onClick(View v) { } } - private static class TintOnLoad implements RequestListener { + private static class TintOnLoad implements RequestListener { private ImageView imageView; private int tintColor; @@ -114,13 +120,13 @@ public TintOnLoad(ImageView view, int tintColor) { } @Override - public boolean onException(Exception e, Integer model, Target target, boolean isFirstResource) { + public boolean onResourceReady(Drawable resource, Object model, Target target, DataSource dataSource, boolean isFirstResource) { + imageView.setColorFilter(tintColor); return false; } @Override - public boolean onResourceReady(GlideDrawable resource, Integer model, Target target, boolean isFromMemoryCache, boolean isFirstResource) { - imageView.setColorFilter(tintColor); + public boolean onLoadFailed(@Nullable GlideException e, Object model, Target target, boolean isFirstResource) { return false; } } diff --git a/sample/src/main/java/com/yarolegovich/discretescrollview/sample/weather/ForecastView.java b/sample/src/main/java/com/yarolegovich/discretescrollview/sample/weather/ForecastView.java index 821c992..15b5c46 100644 --- a/sample/src/main/java/com/yarolegovich/discretescrollview/sample/weather/ForecastView.java +++ b/sample/src/main/java/com/yarolegovich/discretescrollview/sample/weather/ForecastView.java @@ -6,9 +6,6 @@ import android.graphics.LinearGradient; import android.graphics.Paint; import android.graphics.Shader; -import android.os.Build; -import android.support.annotation.ArrayRes; -import android.support.annotation.RequiresApi; import android.util.AttributeSet; import android.view.Gravity; import android.view.animation.AccelerateDecelerateInterpolator; @@ -16,6 +13,8 @@ import android.widget.LinearLayout; import android.widget.TextView; +import androidx.annotation.ArrayRes; + import com.bumptech.glide.Glide; import com.yarolegovich.discretescrollview.sample.R; @@ -46,11 +45,6 @@ public ForecastView(Context context, AttributeSet attrs, int defStyleAttr) { super(context, attrs, defStyleAttr); } - @RequiresApi(api = Build.VERSION_CODES.LOLLIPOP) - public ForecastView(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) { - super(context, attrs, defStyleAttr, defStyleRes); - } - { evaluator = new ArgbEvaluator(); @@ -61,9 +55,9 @@ public ForecastView(Context context, AttributeSet attrs, int defStyleAttr, int d setGravity(Gravity.CENTER_HORIZONTAL); inflate(getContext(), R.layout.view_forecast, this); - weatherDescription = (TextView) findViewById(R.id.weather_description); - weatherImage = (ImageView) findViewById(R.id.weather_image); - weatherTemperature = (TextView) findViewById(R.id.weather_temperature); + weatherDescription = findViewById(R.id.weather_description); + weatherImage = findViewById(R.id.weather_image); + weatherTemperature = findViewById(R.id.weather_temperature); } private void initGradient() { diff --git a/sample/src/main/java/com/yarolegovich/discretescrollview/sample/weather/WeatherActivity.java b/sample/src/main/java/com/yarolegovich/discretescrollview/sample/weather/WeatherActivity.java index 6f2cc4e..e69c7fa 100644 --- a/sample/src/main/java/com/yarolegovich/discretescrollview/sample/weather/WeatherActivity.java +++ b/sample/src/main/java/com/yarolegovich/discretescrollview/sample/weather/WeatherActivity.java @@ -1,11 +1,13 @@ package com.yarolegovich.discretescrollview.sample.weather; import android.os.Bundle; -import android.support.annotation.NonNull; -import android.support.annotation.Nullable; -import android.support.v7.app.AppCompatActivity; import android.view.View; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.appcompat.app.AppCompatActivity; +import androidx.recyclerview.widget.RecyclerView; + import com.yarolegovich.discretescrollview.DiscreteScrollView; import com.yarolegovich.discretescrollview.sample.R; import com.yarolegovich.discretescrollview.sample.DiscreteScrollViewOptions; @@ -32,10 +34,10 @@ protected void onCreate(@Nullable Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.activity_weather); - forecastView = (ForecastView) findViewById(R.id.forecast_view); + forecastView = findViewById(R.id.forecast_view); forecasts = WeatherStation.get().getForecasts(); - cityPicker = (DiscreteScrollView) findViewById(R.id.forecast_city_picker); + cityPicker = findViewById(R.id.forecast_city_picker); cityPicker.setSlideOnFling(true); cityPicker.setAdapter(new ForecastAdapter(forecasts)); cityPicker.addOnItemChangedListener(this); @@ -74,7 +76,9 @@ public void onScroll( @Nullable ForecastAdapter.ViewHolder currentHolder, @Nullable ForecastAdapter.ViewHolder newHolder) { Forecast current = forecasts.get(currentIndex); - if (newIndex >= 0 && newIndex < cityPicker.getAdapter().getItemCount()) { + RecyclerView.Adapter adapter = cityPicker.getAdapter(); + int itemCount = adapter != null ? adapter.getItemCount() : 0; + if (newIndex >= 0 && newIndex < itemCount) { Forecast next = forecasts.get(newIndex); forecastView.onScroll(1f - Math.abs(position), current, next); } diff --git a/sample/src/main/res/layout/activity_gallery.xml b/sample/src/main/res/layout/activity_gallery.xml index 2ad6a6d..cbdb34d 100644 --- a/sample/src/main/res/layout/activity_gallery.xml +++ b/sample/src/main/res/layout/activity_gallery.xml @@ -1,5 +1,5 @@ - - - \ No newline at end of file + \ No newline at end of file diff --git a/sample/src/main/res/layout/activity_main.xml b/sample/src/main/res/layout/activity_main.xml index cb68fb1..f873a65 100644 --- a/sample/src/main/res/layout/activity_main.xml +++ b/sample/src/main/res/layout/activity_main.xml @@ -7,12 +7,12 @@ android:layout_height="match_parent" tools:context="com.yarolegovich.discretescrollview.sample.MainActivity"> - - + @@ -79,7 +81,7 @@ android:layout_width="16dp" android:layout_height="wrap_content" /> - @@ -118,7 +121,8 @@ android:layout_weight="1" android:text="@string/btn_smooth_scroll" android:textAllCaps="true" - android:textColor="@color/shopAccent" /> + android:textColor="@color/shopAccent" + tools:ignore="NestedWeights" />