diff --git a/README.md b/README.md index e074fde..77748c8 100644 --- a/README.md +++ b/README.md @@ -8,8 +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.1.3' +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
@@ -42,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 @@ -72,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() @@ -83,10 +95,51 @@ cityPicker.setItemTransformer(new ScaleTransformer.Builder() ``` You may see how it works on GIFs. +#### Slide through multiple items + +To allow slide through multiple items call: +```java +scrollView.setSlideOnFling(true); +``` +The default threshold is set to 2100. Lower the threshold, more fluid the animation. You can adjust the threshold by calling: +```java +scrollView.setSlideOnFlingThreshold(value); +``` + +#### Infinite scroll +Infinite scroll is implemented on the adapter level: +```java +InfiniteScrollAdapter wrapper = InfiniteScrollAdapter.wrap(yourAdapter); +scrollView.setAdapter(wrapper); +``` +An instance of `InfiniteScrollAdapter` has the following useful methods: +```java +int getRealItemCount(); + +int getRealCurrentPosition(); + +int getRealPosition(int position); + +/* + * You will probably want this method in the following use case: + * int targetAdapterPosition = wrapper.getClosestPosition(targetPosition); + * scrollView.smoothScrollTo(targetAdapterPosition); + * To scroll the data set for the least required amount to reach targetPosition. + */ +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 -scrollView.setScrollStateChangeListener(listener); +scrollView.addScrollStateChangeListener(listener); +scrollView.removeScrollStateChangeListener(listener); public interface ScrollStateChangeListener { @@ -101,31 +154,36 @@ public interface ScrollStateChangeListener { * -view1 is on position -1; * -currentlySelectedView is on position 0; * -view2 is on position 1. + * @param currentIndex - index of current view + * @param newIndex - index of a view which is becoming the new current * @param currentHolder - ViewHolder of a current view - * @param newCurrent - ViewHolder of a view that moved closer to the center + * @param newCurrent - ViewHolder of a view which is becoming the new current */ - void onScroll(float scrollPosition, @NonNull T currentHolder, @NonNull T newCurrentHolder); + void onScroll(float scrollPosition, int currentIndex, int newIndex, @Nullable T currentHolder, @Nullable T newCurrentHolder); } ``` * Scroll: ```java -scrollView.setScrollListener(listener); +scrollView.addScrollListener(listener); +scrollView.removeScrollListener(listener); public interface ScrollListener { //The same as ScrollStateChangeListener, but for the cases when you are interested only in onScroll() - void onScroll(float scrollPosition, @NonNull T currentHolder, @NonNull T newCurrentHolder); + void onScroll(float scrollPosition, int currentIndex, int newIndex, @Nullable T currentHolder, @Nullable T newCurrentHolder); } ``` * Current selection changes: ```java -scrollView.setOnItemChangedListener(listener); +scrollView.addOnItemChangedListener(listener); +scrollView.removeOnItemChangedListener(listener); public interface OnItemChangedListener { /** * Called when new item is selected. It is similar to the onScrollEnd of ScrollStateChangeListener, except that it is * also called when currently selected item appears on the screen for the first time. + * viewHolder will be null, if data set becomes empty */ - void onCurrentItemChanged(@NonNull T viewHolder, int adapterPosition); + void onCurrentItemChanged(@Nullable T viewHolder, int adapterPosition); } ``` diff --git a/build.gradle b/build.gradle index f16ba00..66fd81e 100644 --- a/build.gradle +++ b/build.gradle @@ -1,16 +1,20 @@ buildscript { repositories { jcenter() + google() } dependencies { - classpath 'com.android.tools.build:gradle:2.2.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' } } allprojects { repositories { jcenter() + maven { url "https://maven.google.com" } + maven { url "https://jitpack.io" } + google() } } @@ -19,10 +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.1.3' - licences = ['Apache-2.0'] + compileSdkVersion = 29 + buildToolsVersion = '29.0.2' + targetSdkVersion = 29 + + 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' + ] + + 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.jar b/gradle/wrapper/gradle-wrapper.jar index 13372ae..16e75d4 100644 Binary files a/gradle/wrapper/gradle-wrapper.jar and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 04e285f..b670cfc 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ -#Mon Dec 28 10:00:20 PST 2015 +#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-2.14.1-all.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-6.1.1-all.zip diff --git a/gradlew b/gradlew old mode 100644 new mode 100755 index 9d82f78..4453cce --- a/gradlew +++ b/gradlew @@ -1,4 +1,4 @@ -#!/usr/bin/env bash +#!/usr/bin/env sh ############################################################################## ## @@ -6,12 +6,30 @@ ## ############################################################################## -# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. -DEFAULT_JVM_OPTS="" +# Attempt to set APP_HOME +# Resolve links: $0 may be a link +PRG="$0" +# Need this for relative symlinks. +while [ -h "$PRG" ] ; do + ls=`ls -ld "$PRG"` + link=`expr "$ls" : '.*-> \(.*\)$'` + if expr "$link" : '/.*' > /dev/null; then + PRG="$link" + else + PRG=`dirname "$PRG"`"/$link" + fi +done +SAVED="`pwd`" +cd "`dirname \"$PRG\"`/" >/dev/null +APP_HOME="`pwd -P`" +cd "$SAVED" >/dev/null APP_NAME="Gradle" APP_BASE_NAME=`basename "$0"` +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS="" + # Use the maximum available, or set MAX_FD != -1 to use that value. MAX_FD="maximum" @@ -30,6 +48,7 @@ die ( ) { cygwin=false msys=false darwin=false +nonstop=false case "`uname`" in CYGWIN* ) cygwin=true @@ -40,26 +59,11 @@ case "`uname`" in MINGW* ) msys=true ;; + NONSTOP* ) + nonstop=true + ;; esac -# Attempt to set APP_HOME -# Resolve links: $0 may be a link -PRG="$0" -# Need this for relative symlinks. -while [ -h "$PRG" ] ; do - ls=`ls -ld "$PRG"` - link=`expr "$ls" : '.*-> \(.*\)$'` - if expr "$link" : '/.*' > /dev/null; then - PRG="$link" - else - PRG=`dirname "$PRG"`"/$link" - fi -done -SAVED="`pwd`" -cd "`dirname \"$PRG\"`/" >/dev/null -APP_HOME="`pwd -P`" -cd "$SAVED" >/dev/null - CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar # Determine the Java command to use to start the JVM. @@ -85,7 +89,7 @@ location of your Java installation." fi # Increase the maximum file descriptors if we can. -if [ "$cygwin" = "false" -a "$darwin" = "false" ] ; then +if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then MAX_FD_LIMIT=`ulimit -H -n` if [ $? -eq 0 ] ; then if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then @@ -150,11 +154,19 @@ if $cygwin ; then esac fi -# Split up the JVM_OPTS And GRADLE_OPTS values into an array, following the shell quoting and substitution rules -function splitJvmOpts() { - JVM_OPTS=("$@") +# Escape application args +save ( ) { + for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done + echo " " } -eval splitJvmOpts $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS -JVM_OPTS[${#JVM_OPTS[*]}]="-Dorg.gradle.appname=$APP_BASE_NAME" +APP_ARGS=$(save "$@") + +# Collect all arguments for the java command, following the shell quoting and substitution rules +eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS" + +# by default we should be in the correct project dir, but when run from Finder on Mac, the cwd is wrong +if [ "$(uname)" = "Darwin" ] && [ "$HOME" = "$PWD" ]; then + cd "$(dirname "$0")" +fi -exec "$JAVACMD" "${JVM_OPTS[@]}" -classpath "$CLASSPATH" org.gradle.wrapper.GradleWrapperMain "$@" +exec "$JAVACMD" "$@" diff --git a/gradlew.bat b/gradlew.bat index 8a0b282..e95643d 100644 --- a/gradlew.bat +++ b/gradlew.bat @@ -1,90 +1,84 @@ -@if "%DEBUG%" == "" @echo off -@rem ########################################################################## -@rem -@rem Gradle startup script for Windows -@rem -@rem ########################################################################## - -@rem Set local scope for the variables with windows NT shell -if "%OS%"=="Windows_NT" setlocal - -@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. -set DEFAULT_JVM_OPTS= - -set DIRNAME=%~dp0 -if "%DIRNAME%" == "" set DIRNAME=. -set APP_BASE_NAME=%~n0 -set APP_HOME=%DIRNAME% - -@rem Find java.exe -if defined JAVA_HOME goto findJavaFromJavaHome - -set JAVA_EXE=java.exe -%JAVA_EXE% -version >NUL 2>&1 -if "%ERRORLEVEL%" == "0" goto init - -echo. -echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. -echo. -echo Please set the JAVA_HOME variable in your environment to match the -echo location of your Java installation. - -goto fail - -:findJavaFromJavaHome -set JAVA_HOME=%JAVA_HOME:"=% -set JAVA_EXE=%JAVA_HOME%/bin/java.exe - -if exist "%JAVA_EXE%" goto init - -echo. -echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% -echo. -echo Please set the JAVA_HOME variable in your environment to match the -echo location of your Java installation. - -goto fail - -:init -@rem Get command-line arguments, handling Windowz variants - -if not "%OS%" == "Windows_NT" goto win9xME_args -if "%@eval[2+2]" == "4" goto 4NT_args - -:win9xME_args -@rem Slurp the command line arguments. -set CMD_LINE_ARGS= -set _SKIP=2 - -:win9xME_args_slurp -if "x%~1" == "x" goto execute - -set CMD_LINE_ARGS=%* -goto execute - -:4NT_args -@rem Get arguments from the 4NT Shell from JP Software -set CMD_LINE_ARGS=%$ - -:execute -@rem Setup the command line - -set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar - -@rem Execute Gradle -"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS% - -:end -@rem End local scope for the variables with windows NT shell -if "%ERRORLEVEL%"=="0" goto mainEnd - -:fail -rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of -rem the _cmd.exe /c_ return code! -if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 -exit /b 1 - -:mainEnd -if "%OS%"=="Windows_NT" endlocal - -:omega +@if "%DEBUG%" == "" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%" == "" set DIRNAME=. +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS= + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if "%ERRORLEVEL%" == "0" goto init + +echo. +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto init + +echo. +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:init +@rem Get command-line arguments, handling Windows variants + +if not "%OS%" == "Windows_NT" goto win9xME_args + +:win9xME_args +@rem Slurp the command line arguments. +set CMD_LINE_ARGS= +set _SKIP=2 + +:win9xME_args_slurp +if "x%~1" == "x" goto execute + +set CMD_LINE_ARGS=%* + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS% + +:end +@rem End local scope for the variables with windows NT shell +if "%ERRORLEVEL%"=="0" goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 +exit /b 1 + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/library/build.gradle b/library/build.gradle index 524a226..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 25 - buildToolsVersion "25.0.2" + compileSdkVersion rootProject.compileSdkVersion + buildToolsVersion rootProject.buildToolsVersion defaultConfig { minSdkVersion 14 - targetSdkVersion 25 + targetSdkVersion rootProject.targetSdkVersion versionCode 1 versionName "1.0" + + testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" } } dependencies { - compile 'com.android.support:appcompat-v7:25.2.0' - compile 'com.android.support:recyclerview-v7:25.2.0' -} + 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 8617e6f..d3fcf49 100644 --- a/library/src/main/java/com/yarolegovich/discretescrollview/Direction.java +++ b/library/src/main/java/com/yarolegovich/discretescrollview/Direction.java @@ -10,16 +10,40 @@ enum Direction { public int applyTo(int delta) { return delta * -1; } + + @Override + public boolean sameAs(int direction) { + return direction < 0; + } + + @Override + public Direction reverse() { + return Direction.END; + } }, END { @Override public int applyTo(int delta) { return delta; } + + @Override + 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 738fc65..c22fba6 100644 --- a/library/src/main/java/com/yarolegovich/discretescrollview/DiscreteScrollLayoutManager.java +++ b/library/src/main/java/com/yarolegovich/discretescrollview/DiscreteScrollLayoutManager.java @@ -5,109 +5,152 @@ 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; - private static final int NO_POSITION = -1; - private static final int DEFAULT_TIME_FOR_ITEM_SETTLE = 150; + 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; + + protected int scrolled; + protected int pendingScroll; + protected int currentPosition; + protected int pendingPosition; - private Orientation.Helper orientationHelper; + protected SparseArray detachedCache; - private int scrolled; - private int pendingScroll; - private int currentPosition; - private int pendingPosition; + private DSVOrientation.Helper orientationHelper; + + protected boolean isFirstOrEmptyLayout; private Context context; private int timeForItemSettle; private int offscreenItems; + private int transformClampItemCount; + + private boolean dataSetChangeShiftedPosition; + + private int flingThreshold; + private boolean shouldSlideOnFling; + + private int viewWidth, viewHeight; - private SparseArray detachedCache; + @NonNull + private DSVScrollConfig scrollConfig = DSVScrollConfig.ENABLED; @NonNull private final ScrollStateListener scrollStateListener; private DiscreteScrollItemTransformer itemTransformer; + private RecyclerViewProxy recyclerViewProxy; + public DiscreteScrollLayoutManager( - Context c, + @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; this.currentPosition = NO_POSITION; + this.flingThreshold = DEFAULT_FLING_THRESHOLD; + this.shouldSlideOnFling = false; this.recyclerCenter = new Point(); this.currentViewCenter = new Point(); this.viewCenterIterator = new Point(); 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; } - boolean isFirstOrEmptyLayout = getChildCount() == 0; - if (isFirstOrEmptyLayout) { - initChildDimensions(recycler); - } + ensureValidPosition(state); - updateRecyclerDimensions(); + updateRecyclerDimensions(state); - detachAndScrapAttachedViews(recycler); + //onLayoutChildren may be called multiple times and this check is required so that the flag + //won't be cleared until onLayoutCompleted + if (!isFirstOrEmptyLayout) { + isFirstOrEmptyLayout = recyclerViewProxy.getChildCount() == 0; + if (isFirstOrEmptyLayout) { + initChildDimensions(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) { scrollStateListener.onCurrentViewFirstLayout(); + isFirstOrEmptyLayout = false; + } else if (dataSetChangeShiftedPosition) { + scrollStateListener.onDataSetChangeChangedPosition(); + dataSetChangeShiftedPosition = false; } } - 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; @@ -118,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)) { @@ -143,78 +198,102 @@ 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) { final int positionStep = direction.applyTo(1); + //Predictive layout is required when we are doing smooth fast scroll towards pendingPosition + boolean noPredictiveLayoutRequired = pendingPosition == NO_POSITION + || !direction.sameAs(pendingPosition - currentPosition); + viewCenterIterator.set(currentViewCenter.x, currentViewCenter.y); - for (int i = currentPosition + positionStep; isInBounds(i); i += positionStep) { + for (int pos = currentPosition + positionStep; isInBounds(pos); pos += positionStep) { + if (pos == pendingPosition) { + noPredictiveLayoutRequired = true; + } orientationHelper.shiftViewCenter(direction, scrollToChangeCurrent, viewCenterIterator); if (isViewVisible(viewCenterIterator, endBound)) { - layoutView(recycler, i, viewCenterIterator); + layoutView(recycler, pos, viewCenterIterator); + } else if (noPredictiveLayoutRequired) { + break; } } } - 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) { - currentPosition = 0; + newPosition = 0; } else if (currentPosition >= positionStart) { - currentPosition = 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) { - if (getItemCount() == 0) { - currentPosition = NO_POSITION; + public void onItemsRemoved(@NonNull RecyclerView recyclerView, int positionStart, int itemCount) { + int newPosition = currentPosition; + if (recyclerViewProxy.getItemCount() == 0) { + newPosition = NO_POSITION; } else if (currentPosition >= positionStart) { - currentPosition = Math.max(0, currentPosition - itemCount); + if (currentPosition < positionStart + itemCount) { + //If currentPosition is in the removed items, then the new item became current + currentPosition = NO_POSITION; + } + newPosition = Math.max(0, currentPosition - itemCount); } + onNewPosition(newPosition); } @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; + } + + private void onNewPosition(int position) { + if (currentPosition != position) { + currentPosition = position; + dataSetChangeShiftedPosition = true; + } } @Override @@ -227,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; } @@ -244,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); @@ -257,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); } } } @@ -273,22 +354,21 @@ public void scrollToPosition(int position) { } currentPosition = position; - requestLayout(); + recyclerViewProxy.requestLayout(); } @Override public void smoothScrollToPosition(RecyclerView recyclerView, RecyclerView.State state, int position) { - if (currentPosition == position) { + if (currentPosition == position || pendingPosition != NO_POSITION) { return; } - - pendingScroll = -scrolled; - Direction direction = Direction.fromDelta(position - currentPosition); - int distanceToScroll = Math.abs(position - currentPosition) * scrollToChangeCurrent; - pendingScroll += direction.applyTo(distanceToScroll); - - pendingPosition = position; - startSmoothPendingScroll(); + checkTargetPosition(state, position); + if (currentPosition == NO_POSITION) { + //Layout not happened yet + currentPosition = position; + } else { + startSmoothPendingScroll(position); + } } @Override @@ -372,16 +452,21 @@ 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 newPosition = currentPosition + Direction.fromDelta(velocity).applyTo(1); + int throttleValue = shouldSlideOnFling ? Math.abs(velocity / flingThreshold) : 1; + int newPosition = currentPosition + Direction.fromDelta(velocity).applyTo(throttleValue); + newPosition = checkNewOnFlingPositionIsInBounds(newPosition); boolean isInScrollDirection = velocity * scrolled >= 0; - boolean canFling = isInScrollDirection && newPosition >= 0 && newPosition < getItemCount(); + boolean canFling = isInScrollDirection && isInBounds(newPosition); if (canFling) { - pendingScroll = getHowMuchIsLeftToScroll(velocity); - if (pendingScroll != 0) { - startSmoothPendingScroll(); - } + startSmoothPendingScroll(newPosition); } else { returnToCurrentPosition(); } @@ -394,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; @@ -405,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); @@ -422,17 +512,86 @@ 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) { + if (currentPosition == position) return; + pendingScroll = -scrolled; + Direction direction = Direction.fromDelta(position - currentPosition); + int distanceToScroll = Math.abs(position - currentPosition) * scrollToChangeCurrent; + pendingScroll += direction.applyTo(distanceToScroll); + pendingPosition = 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) { - if (newAdapter.getItemCount() > 0) { - pendingPosition = NO_POSITION; - scrolled = pendingScroll = 0; + pendingPosition = NO_POSITION; + scrolled = pendingScroll = 0; + if (newAdapter instanceof InitialPositionProvider) { + currentPosition = ((InitialPositionProvider) newAdapter).getInitialPosition(); + } else { currentPosition = 0; } - removeAllViews(); + recyclerViewProxy.removeAllViews(); } @Override @@ -458,6 +617,16 @@ public RecyclerView.LayoutParams generateDefaultLayoutParams() { ViewGroup.LayoutParams.WRAP_CONTENT); } + public int getNextPosition() { + if (scrolled == 0) { + return currentPosition; + } else if (pendingPosition != NO_POSITION) { + return pendingPosition; + } else { + return currentPosition + Direction.fromDelta(scrolled).applyTo(1); + } + } + public void setItemTransformer(DiscreteScrollItemTransformer itemTransformer) { this.itemTransformer = itemTransformer; } @@ -469,13 +638,30 @@ public void setTimeForItemSettle(int timeForItemSettle) { public void setOffscreenItems(int offscreenItems) { this.offscreenItems = offscreenItems; extraLayoutSpace = scrollToChangeCurrent * offscreenItems; - requestLayout(); + recyclerViewProxy.requestLayout(); } - public void setOrientation(Orientation orientation) { + public void setTransformClampItemCount(int transformClampItemCount) { + this.transformClampItemCount = transformClampItemCount; + applyItemTransformToChildren(); + } + + public void setOrientation(DSVOrientation orientation) { orientationHelper = orientation.createHelper(); - removeAllViews(); - requestLayout(); + recyclerViewProxy.removeAllViews(); + recyclerViewProxy.requestLayout(); + } + + public void setShouldSlideOnFling(boolean result) { + shouldSlideOnFling = result; + } + + public void setSlideOnFlingThreshold(int threshold) { + flingThreshold = threshold; + } + + public void setScrollConfig(@NonNull DSVScrollConfig config) { + scrollConfig = config; } public int getCurrentPosition() { @@ -483,20 +669,34 @@ public int getCurrentPosition() { } @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 != itemCount - 1 && position >= itemCount) { + return itemCount - 1; + } + return position; } private int getHowMuchIsLeftToScroll(int dx) { @@ -504,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() { @@ -520,12 +720,15 @@ public int getExtraLayoutSpace() { } private void notifyScroll() { - float position = -Math.min(Math.max(-1f, scrolled / (float) scrollToChangeCurrent), 1f); + float amountToScroll = pendingPosition != NO_POSITION ? + 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) { @@ -534,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) { @@ -575,6 +794,11 @@ public interface ScrollStateListener { void onScroll(float currentViewPosition); void onCurrentViewFirstLayout(); + + 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 209bbb1..560e5d0 100644 --- a/library/src/main/java/com/yarolegovich/discretescrollview/DiscreteScrollView.java +++ b/library/src/main/java/com/yarolegovich/discretescrollview/DiscreteScrollView.java @@ -2,28 +2,42 @@ 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; +import java.util.ArrayList; +import java.util.List; + /** * Created by yarolegovich on 18.02.2017. */ -@SuppressWarnings("unchecked") +@SuppressWarnings({"unchecked", "rawtypes"}) public class DiscreteScrollView extends RecyclerView { - private static final int DEFAULT_ORIENTATION = Orientation.HORIZONTAL.ordinal(); + public static final int NO_POSITION = DiscreteScrollLayoutManager.NO_POSITION; + + private static final int DEFAULT_ORIENTATION = DSVOrientation.HORIZONTAL.ordinal(); private DiscreteScrollLayoutManager layoutManager; - private ScrollStateChangeListener scrollStateChangeListener; - private OnItemChangedListener onItemChangedListener; + 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); @@ -41,6 +55,9 @@ public DiscreteScrollView(Context context, AttributeSet attrs, int defStyleAttr) } private void init(AttributeSet attrs) { + scrollStateChangeListeners = new ArrayList<>(); + onItemChangedListeners = new ArrayList<>(); + int orientation = DEFAULT_ORIENTATION; if (attrs != null) { TypedArray ta = getContext().obtainStyledAttributes(attrs, R.styleable.DiscreteScrollView); @@ -48,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); } @@ -66,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); @@ -81,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 */ @@ -96,7 +127,15 @@ public void setItemTransitionTimeMillis(@IntRange(from = 10) int millis) { layoutManager.setTimeForItemSettle(millis); } - public void setOrientation(Orientation orientation) { + public void setSlideOnFling(boolean result){ + layoutManager.setShouldSlideOnFling(result); + } + + public void setSlideOnFlingThreshold(int threshold){ + layoutManager.setSlideOnFlingThreshold(threshold); + } + + public void setOrientation(DSVOrientation orientation) { layoutManager.setOrientation(orientation); } @@ -104,83 +143,146 @@ public void setOffscreenItems(int items) { layoutManager.setOffscreenItems(items); } - public void setScrollStateChangeListener(ScrollStateChangeListener scrollStateChangeListener) { - this.scrollStateChangeListener = scrollStateChangeListener; + 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); + } + + public void addScrollListener(@NonNull ScrollListener scrollListener) { + addScrollStateChangeListener(new ScrollListenerAdapter(scrollListener)); + } + + public void addOnItemChangedListener(@NonNull OnItemChangedListener onItemChangedListener) { + onItemChangedListeners.add(onItemChangedListener); + } + + public void removeScrollStateChangeListener(@NonNull ScrollStateChangeListener scrollStateChangeListener) { + scrollStateChangeListeners.remove(scrollStateChangeListener); + } + + public void removeScrollListener(@NonNull ScrollListener scrollListener) { + removeScrollStateChangeListener(new ScrollListenerAdapter<>(scrollListener)); + } + + public void removeItemChangedListener(@NonNull OnItemChangedListener onItemChangedListener) { + onItemChangedListeners.remove(onItemChangedListener); } - public void setScrollListener(ScrollListener scrollListener) { - setScrollStateChangeListener(new ScrollListenerAdapter(scrollListener)); + private void notifyScrollStart(ViewHolder holder, int current) { + for (ScrollStateChangeListener listener : scrollStateChangeListeners) { + listener.onScrollStart(holder, current); + } } - public void setOnItemChangedListener(OnItemChangedListener onItemChangedListener) { - this.onItemChangedListener = onItemChangedListener; + private void notifyScrollEnd(ViewHolder holder, int current) { + for (ScrollStateChangeListener listener : scrollStateChangeListeners) { + listener.onScrollEnd(holder, current); + } + } + + private void notifyScroll(float position, + int currentIndex, int newIndex, + ViewHolder currentHolder, ViewHolder newHolder) { + for (ScrollStateChangeListener listener : scrollStateChangeListeners) { + listener.onScroll(position, currentIndex, newIndex, + currentHolder, + newHolder); + } + } + + private void notifyCurrentItemChanged(ViewHolder holder, int current) { + for (OnItemChangedListener listener : onItemChangedListeners) { + listener.onCurrentItemChanged(holder, current); + } + } + + private void notifyCurrentItemChanged() { + removeCallbacks(notifyItemChangedRunnable); + if (onItemChangedListeners.isEmpty()) { + return; + } + int current = layoutManager.getCurrentPosition(); + ViewHolder currentHolder = getViewHolder(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() { - if (scrollStateChangeListener != null) { - int current = layoutManager.getCurrentPosition(); - ViewHolder holder = getViewHolder(current); - if (holder != null) { - scrollStateChangeListener.onScrollStart(holder, current); - } + removeCallbacks(notifyItemChangedRunnable); + if (scrollStateChangeListeners.isEmpty()) { + return; + } + int current = layoutManager.getCurrentPosition(); + ViewHolder holder = getViewHolder(current); + if (holder != null) { + notifyScrollStart(holder, current); } } @Override public void onScrollEnd() { - ViewHolder holder = null; - int current = layoutManager.getCurrentPosition(); - if (scrollStateChangeListener != null) { - holder = getViewHolder(current); - if (holder == null) { - return; - } - scrollStateChangeListener.onScrollEnd(holder, current); + if (onItemChangedListeners.isEmpty() && scrollStateChangeListeners.isEmpty()) { + return; } - if (onItemChangedListener != null) { - if (holder == null) { - holder = getViewHolder(current); - } - if (holder != null) { - onItemChangedListener.onCurrentItemChanged(holder, current); - } + int current = layoutManager.getCurrentPosition(); + ViewHolder holder = getViewHolder(current); + if (holder != null) { + notifyScrollEnd(holder, current); + notifyCurrentItemChanged(holder, current); } } @Override public void onScroll(float currentViewPosition) { - if (scrollStateChangeListener != null) { - int current = getCurrentItem(); - ViewHolder currentHolder = getViewHolder(getCurrentItem()); - - int newCurrent = current + (currentViewPosition < 0 ? 1 : -1); - ViewHolder newCurrentHolder = getViewHolder(newCurrent); - - if (currentHolder != null && newCurrentHolder != null) { - scrollStateChangeListener.onScroll( - currentViewPosition, currentHolder, - newCurrentHolder); - } + if (scrollStateChangeListeners.isEmpty()) { + return; + } + int currentIndex = getCurrentItem(); + int newIndex = layoutManager.getNextPosition(); + if (currentIndex != newIndex) { + notifyScroll(currentViewPosition, + currentIndex, newIndex, + getViewHolder(currentIndex), + getViewHolder(newIndex)); } } @Override public void onCurrentViewFirstLayout() { - if (onItemChangedListener != null) { - int current = layoutManager.getCurrentPosition(); - ViewHolder currentHolder = getViewHolder(current); - if (currentHolder != null) { - onItemChangedListener.onCurrentItemChanged(currentHolder, current); - } - } + notifyCurrentItemChanged(); + } + + @Override + public void onDataSetChangeChangedPosition() { + notifyCurrentItemChanged(); } } @@ -190,19 +292,26 @@ public interface ScrollStateChangeListener { void onScrollEnd(@NonNull T currentItemHolder, int adapterPosition); - void onScroll(float scrollPosition, @NonNull T currentHolder, @NonNull T newCurrent); + void onScroll(float scrollPosition, + int currentPosition, + int newPosition, + @Nullable T currentHolder, + @Nullable T newCurrent); } public interface ScrollListener { - void onScroll(float scrollPosition, @NonNull T currentHolder, @NonNull T newCurrent); + void onScroll(float scrollPosition, + int currentPosition, int newPosition, + @Nullable T currentHolder, + @Nullable T newCurrent); } public interface OnItemChangedListener { /* * This method will be also triggered when view appears on the screen for the first time. + * If data set is empty, viewHolder will be null and adapterPosition will be NO_POSITION */ - void onCurrentItemChanged(@NonNull T viewHolder, int adapterPosition); - + void onCurrentItemChanged(@Nullable T viewHolder, int adapterPosition); } } diff --git a/library/src/main/java/com/yarolegovich/discretescrollview/InfiniteScrollAdapter.java b/library/src/main/java/com/yarolegovich/discretescrollview/InfiniteScrollAdapter.java new file mode 100644 index 0000000..803efd5 --- /dev/null +++ b/library/src/main/java/com/yarolegovich/discretescrollview/InfiniteScrollAdapter.java @@ -0,0 +1,179 @@ +package com.yarolegovich.discretescrollview; + +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 + implements DiscreteScrollLayoutManager.InitialPositionProvider { + + private static final int CENTER = Integer.MAX_VALUE / 2; + private static final int RESET_BOUND = 100; + + public static InfiniteScrollAdapter wrap( + @NonNull RecyclerView.Adapter adapter) { + return new InfiniteScrollAdapter<>(adapter); + } + + private RecyclerView.Adapter wrapped; + private DiscreteScrollLayoutManager layoutManager; + + public InfiniteScrollAdapter(@NonNull RecyclerView.Adapter wrapped) { + this.wrapped = wrapped; + this.wrapped.registerAdapterDataObserver(new DataSetChangeDelegate()); + } + + @Override + public void onAttachedToRecyclerView(@NonNull RecyclerView recyclerView) { + wrapped.onAttachedToRecyclerView(recyclerView); + if (recyclerView instanceof DiscreteScrollView) { + layoutManager = (DiscreteScrollLayoutManager) recyclerView.getLayoutManager(); + } else { + String msg = recyclerView.getContext().getString(R.string.dsv_ex_msg_adapter_wrong_recycler); + throw new RuntimeException(msg); + } + } + + @Override + public void onDetachedFromRecyclerView(@NonNull RecyclerView recyclerView) { + wrapped.onDetachedFromRecyclerView(recyclerView); + layoutManager = null; + } + + @Override + public @NonNull T onCreateViewHolder(@NonNull ViewGroup parent, int viewType) { + return wrapped.onCreateViewHolder(parent, viewType); + } + + @Override + 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)); + } + + @Override + public int getItemViewType(int position) { + return wrapped.getItemViewType(mapPositionToReal(position)); + } + + @Override + public int getItemCount() { + return isInfinite() ? Integer.MAX_VALUE : wrapped.getItemCount(); + } + + public int getRealItemCount() { + return wrapped.getItemCount(); + } + + public int getRealCurrentPosition() { + return getRealPosition(layoutManager.getCurrentPosition()); + } + + public int getRealPosition(int position) { + return mapPositionToReal(position); + } + + public int getClosestPosition(int position) { + ensureValidPosition(position); + int adapterCurrent = layoutManager.getCurrentPosition(); + int current = mapPositionToReal(adapterCurrent); + if (position == current) { + return adapterCurrent; + } + 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 { + return distance < wraparoundDistance ? target : wraparoundTarget; + } + } + + private int mapPositionToReal(int position) { + if (position < CENTER) { + int rem = (CENTER - position) % wrapped.getItemCount(); + return rem == 0 ? 0 : wrapped.getItemCount() - rem; + } else { + return (position - CENTER) % wrapped.getItemCount(); + } + } + + 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 + private class DataSetChangeDelegate extends RecyclerView.AdapterDataObserver { + + @Override + public void onChanged() { + setPosition(getInitialPosition()); + notifyDataSetChanged(); + } + + @Override + public void onItemRangeRemoved(int positionStart, int itemCount) { + onChanged(); + } + + @Override + public void onItemRangeInserted(int positionStart, int itemCount) { + onChanged(); + } + + @Override + public void onItemRangeMoved(int fromPosition, int toPosition, int itemCount) { + onChanged(); + } + + @Override + public void onItemRangeChanged(int positionStart, int itemCount) { + notifyItemRangeChanged(0, getItemCount()); + } + + @Override + 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 8ec8d4f..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,26 +1,24 @@ 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. */ - -public class ScaleTransformer implements DiscreteScrollItemTransformer { +public class ScaleTransformer implements DiscreteScrollItemTransformer { private Pivot pivotX; private Pivot pivotY; private float minScale; - private float maxScale; private float maxMinDiff; public ScaleTransformer() { pivotX = Pivot.X.CENTER.create(); pivotY = Pivot.Y.CENTER.create(); minScale = 0.8f; - maxScale = 1f; - maxMinDiff = maxScale - minScale; + maxMinDiff = 0.2f; } @Override @@ -36,9 +34,11 @@ public void transformItem(View item, float position) { public static class Builder { private ScaleTransformer transformer; + private float maxScale; public Builder() { transformer = new ScaleTransformer(); + maxScale = 1f; } public Builder setMinScale(@FloatRange(from = 0.01) float scale) { @@ -47,7 +47,7 @@ public Builder setMinScale(@FloatRange(from = 0.01) float scale) { } public Builder setMaxScale(@FloatRange(from = 0.01) float scale) { - transformer.maxMinDiff = scale; + maxScale = scale; return this; } @@ -72,7 +72,7 @@ public Builder setPivotY(Pivot pivot) { } public ScaleTransformer build() { - transformer.maxMinDiff = transformer.maxScale - transformer.minScale; + transformer.maxMinDiff = maxScale - transformer.minScale; return transformer; } 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 5de8190..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,7 +1,9 @@ package com.yarolegovich.discretescrollview.util; -import android.support.annotation.NonNull; -import android.support.v7.widget.RecyclerView; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.recyclerview.widget.RecyclerView; import com.yarolegovich.discretescrollview.DiscreteScrollView; @@ -12,7 +14,7 @@ public class ScrollListenerAdapter implements private DiscreteScrollView.ScrollListener adaptee; - public ScrollListenerAdapter(DiscreteScrollView.ScrollListener adaptee) { + public ScrollListenerAdapter(@NonNull DiscreteScrollView.ScrollListener adaptee) { this.adaptee = adaptee; } @@ -27,7 +29,18 @@ public void onScrollEnd(@NonNull T currentItemHolder, int adapterPosition) { } @Override - public void onScroll(float scrollPosition, @NonNull T currentHolder, @NonNull T newCurrentHolder) { - adaptee.onScroll(scrollPosition, currentHolder, newCurrentHolder); + public void onScroll(float scrollPosition, + int currentIndex, int newIndex, + @Nullable T currentHolder, @Nullable T newCurrentHolder) { + adaptee.onScroll(scrollPosition, currentIndex, newIndex, currentHolder, newCurrentHolder); + } + + @Override + public boolean equals(Object obj) { + if (obj instanceof ScrollListenerAdapter) { + return adaptee.equals(((ScrollListenerAdapter) obj).adaptee); + } else { + return super.equals(obj); + } } } diff --git a/library/src/main/res/values/strings.xml b/library/src/main/res/values/strings.xml index fb84655..4909bfb 100644 --- a/library/src/main/res/values/strings.xml +++ b/library/src/main/res/values/strings.xml @@ -1,3 +1,4 @@ You should not set LayoutManager on DiscreteScrollView.class instance. Library uses a special one. Just don\'t call the method. + InfiniteScrollAdapter is supposed to work only with DiscreteScrollView 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 91d0758..f684808 100644 --- a/sample/build.gradle +++ b/sample/build.gradle @@ -1,13 +1,14 @@ apply plugin: 'com.android.application' android { - compileSdkVersion 25 - buildToolsVersion "25.0.2" + compileSdkVersion rootProject.compileSdkVersion + buildToolsVersion rootProject.buildToolsVersion + defaultConfig { applicationId "com.yarolegovich.discretescrollview.sample" minSdkVersion 19 - targetSdkVersion 25 - versionCode 3 + 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:25.2.0' - compile 'com.android.support:cardview-v7:25.2.0' - compile 'com.android.support:design:25.2.0' - - 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 cee1be8..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,17 @@ 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.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; import java.lang.ref.WeakReference; @@ -43,25 +46,35 @@ 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(); - for (int i = 0; i < scrollView.getAdapter().getItemCount(); i++) { + final RecyclerView.Adapter adapter = scrollView.getAdapter(); + int itemCount = (adapter instanceof InfiniteScrollAdapter) ? + ((InfiniteScrollAdapter) adapter).getRealItemCount() : + (adapter != null ? adapter.getItemCount() : 0); + for (int i = 0; i < itemCount; i++) { menu.add(String.valueOf(i + 1)); } popupMenu.setOnMenuItemClickListener(new PopupMenu.OnMenuItemClickListener() { @Override public boolean onMenuItemClick(MenuItem item) { int destination = Integer.parseInt(String.valueOf(item.getTitle())) - 1; + if (adapter instanceof InfiniteScrollAdapter) { + destination = ((InfiniteScrollAdapter) adapter).getClosestPosition(destination); + } scrollView.smoothScrollToPosition(destination); return true; } @@ -73,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 c74fce8..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,12 +2,13 @@ import android.animation.ArgbEvaluator; import android.os.Bundle; -import android.support.annotation.NonNull; -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; @@ -33,10 +34,10 @@ 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.setScrollListener(this); - itemPicker.setOnItemChangedListener(this); + itemPicker.addScrollListener(this); + itemPicker.addOnItemChangedListener(this); itemPicker.scrollToPosition(1); findViewById(R.id.home).setOnClickListener(this); @@ -58,16 +59,22 @@ public void onClick(View v) { @Override public void onScroll( float currentPosition, - @NonNull GalleryAdapter.ViewHolder currentHolder, - @NonNull GalleryAdapter.ViewHolder newCurrent) { - float position = Math.abs(currentPosition); - currentHolder.setOverlayColor(interpolate(position, currentOverlayColor, overlayColor)); - newCurrent.setOverlayColor(interpolate(position, overlayColor, currentOverlayColor)); + int currentIndex, int newIndex, + @Nullable GalleryAdapter.ViewHolder currentHolder, + @Nullable GalleryAdapter.ViewHolder newCurrent) { + if (currentHolder != null && newCurrent != null) { + float position = Math.abs(currentPosition); + currentHolder.setOverlayColor(interpolate(position, currentOverlayColor, overlayColor)); + newCurrent.setOverlayColor(interpolate(position, overlayColor, currentOverlayColor)); + } } @Override - public void onCurrentItemChanged(@NonNull GalleryAdapter.ViewHolder viewHolder, int adapterPosition) { - viewHolder.setOverlayColor(currentOverlayColor); + public void onCurrentItemChanged(@Nullable GalleryAdapter.ViewHolder viewHolder, int adapterPosition) { + //viewHolder will never be null, because we never remove items from adapter's list + if (viewHolder != null) { + viewHolder.setOverlayColor(currentOverlayColor); + } } private void share(View view) { 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 f0d95ba..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,18 +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.Orientation; +import com.yarolegovich.discretescrollview.InfiniteScrollAdapter; import com.yarolegovich.discretescrollview.sample.DiscreteScrollViewOptions; import com.yarolegovich.discretescrollview.sample.R; import com.yarolegovich.discretescrollview.transform.ScaleTransformer; @@ -23,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; @@ -33,22 +33,24 @@ public class ShopActivity extends AppCompatActivity implements DiscreteScrollVie private TextView currentItemPrice; private ImageView rateItemButton; private DiscreteScrollView itemPicker; + 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.setOnItemChangedListener(this); - itemPicker.setAdapter(new ShopAdapter(data)); + itemPicker = findViewById(R.id.item_picker); + itemPicker.setOrientation(DSVOrientation.HORIZONTAL); + itemPicker.addOnItemChangedListener(this); + infiniteAdapter = InfiniteScrollAdapter.wrap(new ShopAdapter(data)); + itemPicker.setAdapter(infiniteAdapter); itemPicker.setItemTransitionTimeMillis(DiscreteScrollViewOptions.getTransitionTime()); itemPicker.setItemTransformer(new ScaleTransformer.Builder() .setMinScale(0.8f) @@ -69,7 +71,8 @@ protected void onCreate(@Nullable Bundle savedInstanceState) { public void onClick(View v) { switch (v.getId()) { case R.id.item_btn_rate: - Item current = data.get(itemPicker.getCurrentItem()); + int realPosition = infiniteAdapter.getRealPosition(itemPicker.getCurrentItem()); + Item current = data.get(realPosition); shop.setRated(current.getId(), !shop.isRated(current.getId())); changeRateButtonState(current); break; @@ -105,8 +108,9 @@ private void changeRateButtonState(Item item) { } @Override - public void onCurrentItemChanged(@NonNull RecyclerView.ViewHolder viewHolder, int position) { - onItemChanged(data.get(position)); + public void onCurrentItemChanged(@Nullable ShopAdapter.ViewHolder viewHolder, int adapterPosition) { + int positionInDataSet = infiniteAdapter.getRealPosition(adapterPosition); + onItemChanged(data.get(positionInDataSet)); } private void showUnsupportedSnackBar() { 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 c0a8320..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,13 +34,14 @@ 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.setOnItemChangedListener(this); - cityPicker.setScrollStateChangeListener(this); + cityPicker.addOnItemChangedListener(this); + cityPicker.addScrollStateChangeListener(this); cityPicker.scrollToPosition(2); cityPicker.setItemTransitionTimeMillis(DiscreteScrollViewOptions.getTransitionTime()); cityPicker.setItemTransformer(new ScaleTransformer.Builder() @@ -53,9 +56,12 @@ protected void onCreate(@Nullable Bundle savedInstanceState) { } @Override - public void onCurrentItemChanged(@NonNull ForecastAdapter.ViewHolder holder, int position) { - forecastView.setForecast(forecasts.get(position)); - holder.showText(); + public void onCurrentItemChanged(@Nullable ForecastAdapter.ViewHolder holder, int position) { + //viewHolder will never be null, because we never remove items from adapter's list + if (holder != null) { + forecastView.setForecast(forecasts.get(position)); + holder.showText(); + } } @Override @@ -66,12 +72,14 @@ public void onScrollStart(@NonNull ForecastAdapter.ViewHolder holder, int positi @Override public void onScroll( float position, - @NonNull ForecastAdapter.ViewHolder currentHolder, - @NonNull ForecastAdapter.ViewHolder newHolder) { - Forecast current = forecasts.get(cityPicker.getCurrentItem()); - int nextPosition = cityPicker.getCurrentItem() + (position > 0 ? -1 : 1); - if (nextPosition >= 0 && nextPosition < cityPicker.getAdapter().getItemCount()) { - Forecast next = forecasts.get(nextPosition); + int currentIndex, int newIndex, + @Nullable ForecastAdapter.ViewHolder currentHolder, + @Nullable ForecastAdapter.ViewHolder newHolder) { + Forecast current = forecasts.get(currentIndex); + 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" />