diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d191a0c --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +.gradle +/local.properties +local.properties +bintray.properties +/.idea +.DS_Store +/build +*.iml diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..d645695 --- /dev/null +++ b/LICENSE @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/README.md b/README.md new file mode 100644 index 0000000..34ee0f7 --- /dev/null +++ b/README.md @@ -0,0 +1,91 @@ +# DiscreteSeekBar + +This is a copy of [DiscreteSeekBar](https://github.com/AnderWeb/discreteSeekBar) by [Gustavo Claramunt](https://github.com/AnderWeb) + +## Description +DiscreteSeekbar is my poor attempt to develop an android implementation of the [Discrete Slider] component from the Google Material Design Guidelines. + +## Prologe +I really hope Google provides developers with a better (and official) implementation ;) + +## Warning +After a bunch of hours trying to replicate the exact feel of the Material's Discrete Seekbar, with a beautiful stuff-that-morphs-into-other-stuff animation I was convinced about releasing the current code. + +```java +android.util.Log.wtf("WARNING!! HACKERY-DRAGONS!!"); +``` +I've done a few bit of hacky cede and a bunch of things I'm not completely proud of, so use under your sole responsibility (or help me improve it via pull-requests!) + +## Implementation details +This thing runs on minSDK=7 (well, technically could run 4 but can't test since AVDs for api 4 are deprecated and just don't boot). +Obviously some of the subtle animations (navigating with the Keyboard, the Ripple effect, text fade ins/fade outs, etc) are not going to work on APIS lower than 11, but the bubble thing does. And I haven't found a way of improving this with 11-21 APIs, so... + +The base SeekBar is pretty simple. Just 3 drawables for the track, progress and thumb. Some touch event logic to drag, some key event logic to move, and that's all. + +It supports custom ranges (custom min/max), even for negative values. + +The bubble thing **DOESN'T USE** [VectorDrawableMagic] . I was not really needed for such a simple morph. It uses instead an [Animatable Drawable] for the animation with a lot of hackery for callbacks, drawing and a bunch of old simple math. + +>For this to work (and sync with events, etc) I've written a fair amount of shit questionable code... + +The material-floating-thing is composed into the WindowManager (like the typical overflow menus) to be able to show it over other Views without needing to set the SeekBar big enough to account for the (variable) size of he floating thing. + +>For this I'm not sure about the amounts of things I've copied from [PopupWindow] and the possible issues. + +## Usage +This is published in my JFrog repo: + +```groovy +repositories { + maven { + url "https://dl.bintray.com/mardous/Maven" + } +} + +dependencies { + implementation 'com.mardous:discrete-seekbar:1.0.0' +} +``` + +Once imported into your project, you just need to put them into your layous like: + +```xml + +``` + +#### Parameters +You can tweak a few things of the DiscreteSeekbar: + +* **dsb_min**: minimum value +* **dsb_max**: maximum value +* **dsb_value**: current value +* **dsb_mirrorForRtl**: reverse the DiscreteSeekBar for RTL locales +* **dsb_allowTrackClickToDrag**: allows clicking outside the thumb circle to initiate drag. Default TRUE +* **dsb_indicatorFormatter**: a string [Format] to apply to the value inside the bubble indicator. +* **dsb_indicatorPopupEnabled**: choose if the bubble indicator will be shown. Default TRUE + +#### Design + +* **dsb_progressColor**: color/colorStateList for the progress bar and thumb drawable +* **dsb_trackColor**: color/colorStateList for the track drawable +* **dsb_indicatorTextAppearance**: TextAppearance for the bubble indicator +* **dsb_indicatorColor**: color/colorStateList for the bubble shaped drawable +* **dsb_indicatorElevation**: related to android:elevation. Will only be used on API level 21+ +* **dsb_rippleColor**: color/colorStateList for the ripple drawable seen when pressing the thumb. (Yes, it does a kind of "ripple" on API levels lower than 21 and a real RippleDrawable for 21+. +* **dsb_trackHeight**: dimension for the height of the track drawable. +* **dsb_scrubberHeight**: dimension for the height of the scrubber (selected area) drawable. +* **dsb_thumbSize**: dimension for the size of the thumb drawable. +* **dsb_indicatorSeparation**: dimension for the vertical distance from the thumb to the indicator. + +You can also use the attribute **discreteSeekBarStyle** on your themes with a custom Style to be applied to all the DiscreteSeekBars on your app/activity/fragment/whatever. + +[Discrete Slider]:http://www.google.com/design/spec/components/sliders.html#sliders-discrete-slider +[VectorDrawableMagic]:https://developer.android.com/reference/android/graphics/drawable/AnimatedVectorDrawable.html +[Animatable Drawable]:https://developer.android.com/reference/android/graphics/drawable/Animatable.html +[PopupWindow]:https://developer.android.com/reference/android/widget/PopupWindow.html +[Format]:https://developer.android.com/reference/java/util/Formatter.html + diff --git a/build.gradle b/build.gradle new file mode 100644 index 0000000..6176fcf --- /dev/null +++ b/build.gradle @@ -0,0 +1,22 @@ +// Top-level build file where you can add configuration options common to all sub-projects/modules. + +buildscript { + repositories { + jcenter() + google() + } + dependencies { + classpath 'com.android.tools.build:gradle:4.0.1' + classpath 'com.jfrog.bintray.gradle:gradle-bintray-plugin:1.8.4' + classpath 'com.github.dcendents:android-maven-gradle-plugin:1.4.1' + // NOTE: Do not place your application dependencies here; they belong + // in the individual module build.gradle files + } +} + +allprojects { + repositories { + jcenter() + google() + } +} diff --git a/gradle.properties b/gradle.properties new file mode 100644 index 0000000..915f0e6 --- /dev/null +++ b/gradle.properties @@ -0,0 +1,20 @@ +# Project-wide Gradle settings. + +# 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. +# Default value: -Xmx10248m -XX:MaxPermSize=256m +# org.gradle.jvmargs=-Xmx2048m -XX:MaxPermSize=512m -XX:+HeapDumpOnOutOfMemoryError -Dfile.encoding=UTF-8 + +# 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 +android.enableJetifier=true +android.useAndroidX=true \ No newline at end of file diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..8c0fb64 Binary files /dev/null and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..ee6206a --- /dev/null +++ b/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,6 @@ +#Wed Nov 26 21:03:52 CET 2014 +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-6.1.1-all.zip diff --git a/gradlew b/gradlew new file mode 100644 index 0000000..91a7e26 --- /dev/null +++ b/gradlew @@ -0,0 +1,164 @@ +#!/usr/bin/env bash + +############################################################################## +## +## Gradle start up script for UN*X +## +############################################################################## + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS="" + +APP_NAME="Gradle" +APP_BASE_NAME=`basename "$0"` + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD="maximum" + +warn ( ) { + echo "$*" +} + +die ( ) { + echo + echo "$*" + echo + exit 1 +} + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +case "`uname`" in + CYGWIN* ) + cygwin=true + ;; + Darwin* ) + darwin=true + ;; + MINGW* ) + msys=true + ;; +esac + +# For Cygwin, ensure paths are in UNIX format before anything is touched. +if $cygwin ; then + [ -n "$JAVA_HOME" ] && JAVA_HOME=`cygpath --unix "$JAVA_HOME"` +fi + +# 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\"`/" >&- +APP_HOME="`pwd -P`" +cd "$SAVED" >&- + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD="$JAVA_HOME/jre/sh/java" + else + JAVACMD="$JAVA_HOME/bin/java" + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD="java" + which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." +fi + +# Increase the maximum file descriptors if we can. +if [ "$cygwin" = "false" -a "$darwin" = "false" ] ; then + MAX_FD_LIMIT=`ulimit -H -n` + if [ $? -eq 0 ] ; then + if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then + MAX_FD="$MAX_FD_LIMIT" + fi + ulimit -n $MAX_FD + if [ $? -ne 0 ] ; then + warn "Could not set maximum file descriptor limit: $MAX_FD" + fi + else + warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" + fi +fi + +# For Darwin, add options to specify how the application appears in the dock +if $darwin; then + GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" +fi + +# For Cygwin, switch paths to Windows format before running java +if $cygwin ; then + APP_HOME=`cygpath --path --mixed "$APP_HOME"` + CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` + + # We build the pattern for arguments to be converted via cygpath + ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` + SEP="" + for dir in $ROOTDIRSRAW ; do + ROOTDIRS="$ROOTDIRS$SEP$dir" + SEP="|" + done + OURCYGPATTERN="(^($ROOTDIRS))" + # Add a user-defined pattern to the cygpath arguments + if [ "$GRADLE_CYGPATTERN" != "" ] ; then + OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" + fi + # Now convert the arguments - kludge to limit ourselves to /bin/sh + i=0 + for arg in "$@" ; do + CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` + CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option + + if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition + eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` + else + eval `echo args$i`="\"$arg\"" + fi + i=$((i+1)) + done + case $i in + (0) set -- ;; + (1) set -- "$args0" ;; + (2) set -- "$args0" "$args1" ;; + (3) set -- "$args0" "$args1" "$args2" ;; + (4) set -- "$args0" "$args1" "$args2" "$args3" ;; + (5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; + (6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; + (7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; + (8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; + (9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; + 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=("$@") +} +eval splitJvmOpts $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS +JVM_OPTS[${#JVM_OPTS[*]}]="-Dorg.gradle.appname=$APP_BASE_NAME" + +exec "$JAVACMD" "${JVM_OPTS[@]}" -classpath "$CLASSPATH" org.gradle.wrapper.GradleWrapperMain "$@" diff --git a/gradlew.bat b/gradlew.bat new file mode 100644 index 0000000..8a0b282 --- /dev/null +++ b/gradlew.bat @@ -0,0 +1,90 @@ +@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 diff --git a/library/.gitignore b/library/.gitignore new file mode 100644 index 0000000..796b96d --- /dev/null +++ b/library/.gitignore @@ -0,0 +1 @@ +/build diff --git a/library/bintray.gradle b/library/bintray.gradle new file mode 100644 index 0000000..5e5fc77 --- /dev/null +++ b/library/bintray.gradle @@ -0,0 +1,51 @@ +apply plugin: 'com.jfrog.bintray' + +version = libraryVersion + +task sourcesJar(type: Jar) { + from android.sourceSets.main.java.srcDirs + classifier = 'sources' +} + +task javadoc(type: Javadoc) { + source = android.sourceSets.main.java.srcDirs + classpath += project.files(android.getBootClasspath().join(File.pathSeparator)) +} + +task javadocJar(type: Jar, dependsOn: javadoc) { + classifier = 'javadoc' + from javadoc.destinationDir +} + +artifacts { + //archives javadocJar + archives sourcesJar +} + +Properties properties = new Properties() +properties.load( new FileInputStream("local.properties")) + +// Bintray +bintray { + user = properties.getProperty("bintray.user") + key = properties.getProperty("bintray.apikey") + + pkg { + repo = bintrayRepo + name = bintrayName + configurations = ['archives'] + desc = libraryDescription + websiteUrl = siteUrl + vcsUrl = gitUrl + licenses = allLicenses + publish = true + publicDownloadNumbers = true + version { + desc = libraryDescription + gpg { + sign = true + passphrase = properties.getProperty("bintray.gpg.password") + } + } + } +} \ No newline at end of file diff --git a/library/build.gradle b/library/build.gradle new file mode 100644 index 0000000..4719b65 --- /dev/null +++ b/library/build.gradle @@ -0,0 +1,47 @@ +apply plugin: 'com.android.library' +apply from: 'versioning.gradle' + +ext { + bintrayRepo = 'Maven' + bintrayName = 'DiscreteSeekBar' + + publishedGroupId = 'com.mardous' + libraryName = 'DiscreteSeekBar' + artifact = 'discrete-seekbar' + + libraryDescription = 'DiscreteSeekbar is my poor attempt to develop an android implementation of the Discrete Slider component from the Google Material Design Guidelines' + + siteUrl = 'https://github.com/mardous/DiscreteSeekBar' + gitUrl = 'https://github.com/mardous/DiscreteSeekBar.git' + + libraryVersion = '1.0.0' + libraryVersionCode = buildVersionCode(libraryVersion) + + developerId = 'mardous' + developerName = 'Christians Martínez Alvarado' + developerEmail = 'mardous.contact@gmail.com' + + licenseName = 'The Apache Software License, Version 2.0' + licenseUrl = 'http://www.apache.org/licenses/LICENSE-2.0.txt' + allLicenses = ["Apache-2.0"] +} + +android { + compileSdkVersion 30 + buildToolsVersion '30.0.0' + + defaultConfig { + minSdkVersion 16 + targetSdkVersion 30 + versionCode libraryVersionCode + versionName libraryVersion + } +} + +dependencies { + implementation 'androidx.annotation:annotation:1.1.0' + implementation 'androidx.core:core:1.3.1' +} + +apply from: 'maven-install.gradle' +apply from: 'bintray.gradle' \ No newline at end of file diff --git a/library/maven-install.gradle b/library/maven-install.gradle new file mode 100644 index 0000000..837a154 --- /dev/null +++ b/library/maven-install.gradle @@ -0,0 +1,52 @@ +apply plugin: 'com.github.dcendents.android-maven' + +group = publishedGroupId + +install { + repositories.mavenInstaller { + pom { + project { + packaging 'aar' + groupId publishedGroupId + artifactId artifact + name libraryName + description libraryDescription + url siteUrl + + licenses { + license { + name licenseName + url licenseUrl + } + } + + developers { + developer { + id developerId + name developerName + email developerEmail + } + } + + dependencies { + dependency { + groupId 'androidx.core' + artifactId = 'core' + version = '1.3.1' + } + dependency { + groupId 'androidx.annotation' + artifactId = 'annotation' + version = '1.1.0' + } + } + + scm { + connection gitUrl + developerConnection gitUrl + url siteUrl + } + } + } + } +} \ No newline at end of file diff --git a/library/src/main/AndroidManifest.xml b/library/src/main/AndroidManifest.xml new file mode 100644 index 0000000..0e9b6be --- /dev/null +++ b/library/src/main/AndroidManifest.xml @@ -0,0 +1,17 @@ + + + diff --git a/library/src/main/java/com/mardous/discreteseekbar/DiscreteSeekBar.java b/library/src/main/java/com/mardous/discreteseekbar/DiscreteSeekBar.java new file mode 100644 index 0000000..e1da42f --- /dev/null +++ b/library/src/main/java/com/mardous/discreteseekbar/DiscreteSeekBar.java @@ -0,0 +1,1076 @@ +/* + * Copyright (c) Gustavo Claramunt (AnderWeb) 2014. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.mardous.discreteseekbar; + +import android.animation.ValueAnimator; +import android.content.Context; +import android.content.res.ColorStateList; +import android.content.res.TypedArray; +import android.graphics.Canvas; +import android.graphics.Color; +import android.graphics.Rect; +import android.graphics.drawable.Drawable; +import android.os.Build; +import android.os.Parcel; +import android.os.Parcelable; +import android.util.AttributeSet; +import android.util.TypedValue; +import android.view.KeyEvent; +import android.view.MotionEvent; +import android.view.View; +import android.view.ViewConfiguration; +import android.view.ViewGroup; +import android.view.ViewParent; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.core.graphics.drawable.DrawableCompat; +import androidx.core.view.ViewCompat; +import com.mardous.discreteseekbar.internal.PopupIndicator; +import com.mardous.discreteseekbar.internal.compat.SeekBarCompat; +import com.mardous.discreteseekbar.internal.drawable.MarkerDrawable; +import com.mardous.discreteseekbar.internal.drawable.ThumbDrawable; +import com.mardous.discreteseekbar.internal.drawable.TrackRectDrawable; + +import java.util.Formatter; +import java.util.Locale; + +public class DiscreteSeekBar extends View { + + /** + * Interface to propagate seekbar change event + */ + public interface OnProgressChangeListener { + /** + * When the {@link DiscreteSeekBar} value changes + * + * @param seekBar The DiscreteSeekBar + * @param value the new value + * @param fromUser if the change was made from the user or not (i.e. the developer calling {@link #setProgress(int)} + */ + void onProgressChanged(DiscreteSeekBar seekBar, int value, boolean fromUser); + + void onStartTrackingTouch(DiscreteSeekBar seekBar); + + void onStopTrackingTouch(DiscreteSeekBar seekBar); + } + + /** + * Interface to transform the current internal value of this DiscreteSeekBar to anther one for the visualization. + *

+ * This will be used on the floating bubble to display a different value if needed. + *

+ * Using this in conjunction with {@link #setIndicatorFormatter(String)} you will be able to manipulate the + * value seen by the user + * + * @see #setIndicatorFormatter(String) + * @see #setNumericTransformer(DiscreteSeekBar.NumericTransformer) + */ + public static abstract class NumericTransformer { + /** + * Return the desired value to be shown to the user. + * This value will be formatted using the format specified by {@link #setIndicatorFormatter} before displaying it + * + * @param value The value to be transformed + * @return The transformed int + */ + public abstract int transform(int value); + + /** + * Return the desired value to be shown to the user. + * This value will be displayed 'as is' without further formatting. + * + * @param value The value to be transformed + * @return A formatted string + */ + public String transformToString(int value) { + return String.valueOf(value); + } + + /** + * Used to indicate which transform will be used. If this method returns true, + * {@link #transformToString(int)} will be used, otherwise {@link #transform(int)} + * will be used + */ + public boolean useStringTransform() { + return false; + } + } + + + private static class DefaultNumericTransformer extends NumericTransformer { + + @Override + public int transform(int value) { + return value; + } + } + + + private static final boolean isLollipopOrGreater = Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP; + //We want to always use a formatter so the indicator numbers are "translated" to specific locales. + private static final String DEFAULT_FORMATTER = "%d"; + + private static final int PRESSED_STATE = android.R.attr.state_pressed; + private static final int FOCUSED_STATE = android.R.attr.state_focused; + private static final int PROGRESS_ANIMATION_DURATION = 250; + private static final int INDICATOR_DELAY_FOR_TAPS = 150; + private static final int DEFAULT_THUMB_COLOR = 0xff009688; + private static final int SEPARATION_DP = 5; + private ThumbDrawable mThumb; + private TrackRectDrawable mTrack; + private TrackRectDrawable mScrubber; + private Drawable mRipple; + + private int mTrackHeight; + private int mScrubberHeight; + private int mAddedTouchBounds; + + private int mMax; + private int mMin; + private int mValue; + private int mKeyProgressIncrement = 1; + private boolean mMirrorForRtl = false; + private boolean mAllowTrackClick = true; + private boolean mIndicatorPopupEnabled = true; + //We use our own Formatter to avoid creating new instances on every progress change + Formatter mFormatter; + private String mIndicatorFormatter; + private NumericTransformer mNumericTransformer; + private StringBuilder mFormatBuilder; + private OnProgressChangeListener mPublicChangeListener; + private boolean mIsDragging; + private int mDragOffset; + + private Rect mInvalidateRect = new Rect(); + private Rect mTempRect = new Rect(); + private PopupIndicator mIndicator; + private ValueAnimator mPositionAnimator; + private float mAnimationPosition; + private int mAnimationTarget; + private float mDownX; + private float mTouchSlop; + + public DiscreteSeekBar(Context context) { + this(context, null); + } + + public DiscreteSeekBar(Context context, AttributeSet attrs) { + this(context, attrs, R.attr.discreteSeekBarStyle); + } + + public DiscreteSeekBar(Context context, AttributeSet attrs, int defStyleAttr) { + super(context, attrs, defStyleAttr); + setFocusable(true); + setWillNotDraw(false); + + mTouchSlop = ViewConfiguration.get(context).getScaledTouchSlop(); + float density = context.getResources().getDisplayMetrics().density; + + TypedArray a = context.obtainStyledAttributes(attrs, R.styleable.DiscreteSeekBar, + defStyleAttr, R.style.Widget_DiscreteSeekBar); + + int max = 100; + int min = 0; + int value = 0; + mMirrorForRtl = a.getBoolean(R.styleable.DiscreteSeekBar_dsb_mirrorForRtl, mMirrorForRtl); + mAllowTrackClick = a.getBoolean(R.styleable.DiscreteSeekBar_dsb_allowTrackClickToDrag, mAllowTrackClick); + mIndicatorPopupEnabled = a.getBoolean(R.styleable.DiscreteSeekBar_dsb_indicatorPopupEnabled, mIndicatorPopupEnabled); + mTrackHeight = a.getDimensionPixelSize(R.styleable.DiscreteSeekBar_dsb_trackHeight, (int) (1 * density)); + mScrubberHeight = a.getDimensionPixelSize(R.styleable.DiscreteSeekBar_dsb_scrubberHeight, (int) (4 * density)); + int thumbSize = a.getDimensionPixelSize(R.styleable.DiscreteSeekBar_dsb_thumbSize, (int) (density * ThumbDrawable.DEFAULT_SIZE_DP)); + int separation = a.getDimensionPixelSize(R.styleable.DiscreteSeekBar_dsb_indicatorSeparation, + (int) (SEPARATION_DP * density)); + + //Extra pixels for a minimum touch area of 32dp + int touchBounds = (int) (density * 32); + mAddedTouchBounds = Math.max(0, (touchBounds - thumbSize) / 2); + + int indexMax = R.styleable.DiscreteSeekBar_dsb_max; + int indexMin = R.styleable.DiscreteSeekBar_dsb_min; + int indexValue = R.styleable.DiscreteSeekBar_dsb_value; + final TypedValue out = new TypedValue(); + //Not sure why, but we wanted to be able to use dimensions here... + if (a.getValue(indexMax, out)) { + if (out.type == TypedValue.TYPE_DIMENSION) { + max = a.getDimensionPixelSize(indexMax, max); + } else { + max = a.getInteger(indexMax, max); + } + } + if (a.getValue(indexMin, out)) { + if (out.type == TypedValue.TYPE_DIMENSION) { + min = a.getDimensionPixelSize(indexMin, min); + } else { + min = a.getInteger(indexMin, min); + } + } + if (a.getValue(indexValue, out)) { + if (out.type == TypedValue.TYPE_DIMENSION) { + value = a.getDimensionPixelSize(indexValue, value); + } else { + value = a.getInteger(indexValue, value); + } + } + + mMin = min; + mMax = Math.max(min + 1, max); + mValue = Math.max(min, Math.min(max, value)); + updateKeyboardRange(); + + mIndicatorFormatter = a.getString(R.styleable.DiscreteSeekBar_dsb_indicatorFormatter); + + ColorStateList trackColor = a.getColorStateList(R.styleable.DiscreteSeekBar_dsb_trackColor); + ColorStateList progressColor = a.getColorStateList(R.styleable.DiscreteSeekBar_dsb_progressColor); + ColorStateList rippleColor = a.getColorStateList(R.styleable.DiscreteSeekBar_dsb_rippleColor); + boolean editMode = isInEditMode(); + if (editMode || rippleColor == null) { + rippleColor = new ColorStateList(new int[][]{new int[]{}}, new int[]{Color.DKGRAY}); + } + if (editMode || trackColor == null) { + trackColor = new ColorStateList(new int[][]{new int[]{}}, new int[]{Color.GRAY}); + } + if (editMode || progressColor == null) { + progressColor = new ColorStateList(new int[][]{new int[]{}}, new int[]{DEFAULT_THUMB_COLOR}); + } + + mRipple = SeekBarCompat.getRipple(rippleColor); + if (isLollipopOrGreater) { + setBackground(mRipple); + } else { + mRipple.setCallback(this); + } + + TrackRectDrawable shapeDrawable = new TrackRectDrawable(trackColor); + mTrack = shapeDrawable; + mTrack.setCallback(this); + + shapeDrawable = new TrackRectDrawable(progressColor); + mScrubber = shapeDrawable; + mScrubber.setCallback(this); + + mThumb = new ThumbDrawable(progressColor, thumbSize); + mThumb.setCallback(this); + mThumb.setBounds(0, 0, mThumb.getIntrinsicWidth(), mThumb.getIntrinsicHeight()); + + + if (!editMode) { + mIndicator = new PopupIndicator(context, attrs, defStyleAttr, convertValueToMessage(mMax), + thumbSize, thumbSize + mAddedTouchBounds + separation); + mIndicator.setListener(mFloaterListener); + } + a.recycle(); + + setNumericTransformer(new DefaultNumericTransformer()); + + } + + /** + * Sets the current Indicator formatter string + * + * @param formatter + * @see String#format(String, Object...) + * @see #setNumericTransformer(DiscreteSeekBar.NumericTransformer) + */ + public void setIndicatorFormatter(@Nullable String formatter) { + mIndicatorFormatter = formatter; + updateProgressMessage(mValue); + } + + /** + * Sets the current {@link DiscreteSeekBar.NumericTransformer} + * + * @param transformer + * @see #getNumericTransformer() + */ + public void setNumericTransformer(@Nullable NumericTransformer transformer) { + mNumericTransformer = transformer != null ? transformer : new DefaultNumericTransformer(); + //We need to refresh the PopupIndicator view + updateIndicatorSizes(); + updateProgressMessage(mValue); + } + + /** + * Retrieves the current {@link DiscreteSeekBar.NumericTransformer} + * + * @return NumericTransformer + * @see #setNumericTransformer + */ + public NumericTransformer getNumericTransformer() { + return mNumericTransformer; + } + + /** + * Sets the maximum value for this DiscreteSeekBar + * if the supplied argument is smaller than the Current MIN value, + * the MIN value will be set to MAX-1 + *

+ *

+ * Also if the current progress is out of the new range, it will be set to MIN + *

+ * + * @param max + * @see #setMin(int) + * @see #setProgress(int) + */ + public void setMax(int max) { + mMax = max; + if (mMax < mMin) { + setMin(mMax - 1); + } + updateKeyboardRange(); + + if (mValue < mMin || mValue > mMax) { + setProgress(mMin); + } + //We need to refresh the PopupIndicator view + updateIndicatorSizes(); + } + + public int getMax() { + return mMax; + } + + /** + * Sets the minimum value for this DiscreteSeekBar + * if the supplied argument is bigger than the Current MAX value, + * the MAX value will be set to MIN+1 + *

+ * Also if the current progress is out of the new range, it will be set to MIN + *

+ * + * @param min the value. + * @see #setMax(int) + * @see #setProgress(int) + */ + public void setMin(int min) { + mMin = min; + if (mMin > mMax) { + setMax(mMin + 1); + } + updateKeyboardRange(); + + if (mValue < mMin || mValue > mMax) { + setProgress(mMin); + } + } + + public int getMin() { + return mMin; + } + + /** + * Sets the current progress for this DiscreteSeekBar + * The supplied argument will be capped to the current MIN-MAX range + * + * @param progress the progress. + * @see #setMax(int) + * @see #setMin(int) + */ + public void setProgress(int progress) { + setProgress(progress, false); + } + + private void setProgress(int value, boolean fromUser) { + value = Math.max(mMin, Math.min(mMax, value)); + if (isAnimationRunning()) { + mPositionAnimator.cancel(); + } + + if (mValue != value) { + mValue = value; + notifyProgress(value, fromUser); + updateProgressMessage(value); + updateThumbPosFromCurrentProgress(); + } + } + + /** + * Get the current progress + * + * @return the current progress :-P + */ + public int getProgress() { + return mValue; + } + + /** + * Sets a listener to receive notifications of changes to the DiscreteSeekBar's progress level. Also + * provides notifications of when the DiscreteSeekBar shows/hides the bubble indicator. + * + * @param listener The seek bar notification listener + * @see DiscreteSeekBar.OnProgressChangeListener + */ + public void setOnProgressChangeListener(@Nullable OnProgressChangeListener listener) { + mPublicChangeListener = listener; + } + + /** + * Sets the color of the seek thumb, as well as the color of the popup indicator. + * + * @param thumbColor The color the seek thumb will be changed to + * @param indicatorColor The color the popup indicator will be changed to + * The indicator will animate from thumbColor to indicatorColor + * when opening + */ + public void setThumbColor(int thumbColor, int indicatorColor) { + mThumb.setColorStateList(ColorStateList.valueOf(thumbColor)); + mIndicator.setColors(indicatorColor, thumbColor); + } + + /** + * Sets the color of the seek thumb, as well as the color of the popup indicator. + * + * @param thumbColorStateList The ColorStateList the seek thumb will be changed to + * @param indicatorColor The color the popup indicator will be changed to + * The indicator will animate from thumbColorStateList(pressed state) to indicatorColor + * when opening + */ + public void setThumbColor(@NonNull ColorStateList thumbColorStateList, int indicatorColor) { + mThumb.setColorStateList(thumbColorStateList); + //we use the "pressed" color to morph the indicator from it to its own color + int thumbColor = thumbColorStateList.getColorForState(new int[]{PRESSED_STATE}, thumbColorStateList.getDefaultColor()); + mIndicator.setColors(indicatorColor, thumbColor); + } + + /** + * Sets the color of the seekbar scrubber + * + * @param color The color the track scrubber will be changed to + */ + public void setScrubberColor(int color) { + mScrubber.setColorStateList(ColorStateList.valueOf(color)); + } + + /** + * Sets the color of the seekbar scrubber + * + * @param colorStateList The ColorStateList the track scrubber will be changed to + */ + public void setScrubberColor(@NonNull ColorStateList colorStateList) { + mScrubber.setColorStateList(colorStateList); + } + + /** + * Sets the color of the seekbar ripple + * + * @param color The color the track ripple will be changed to + */ + public void setRippleColor(int color) { + setRippleColor(new ColorStateList(new int[][]{new int[]{}}, new int[]{color})); + } + + /** + * Sets the color of the seekbar ripple + * + * @param colorStateList The ColorStateList the track ripple will be changed to + */ + public void setRippleColor(@NonNull ColorStateList colorStateList) { + SeekBarCompat.setRippleColor(mRipple, colorStateList); + } + + /** + * Sets the color of the seekbar scrubber + * + * @param color The color the track will be changed to + */ + public void setTrackColor(int color) { + mTrack.setColorStateList(ColorStateList.valueOf(color)); + } + + /** + * Sets the color of the seekbar scrubber + * + * @param colorStateList The ColorStateList the track will be changed to + */ + public void setTrackColor(@NonNull ColorStateList colorStateList) { + mTrack.setColorStateList(colorStateList); + } + + /** + * If {@code enabled} is false the indicator won't appear. By default popup indicator is + * enabled. + */ + public void setIndicatorPopupEnabled(boolean enabled) { + this.mIndicatorPopupEnabled = enabled; + } + + private void updateIndicatorSizes() { + if (!isInEditMode()) { + if (mNumericTransformer.useStringTransform()) { + mIndicator.updateSizes(mNumericTransformer.transformToString(mMax)); + } else { + mIndicator.updateSizes(convertValueToMessage(mNumericTransformer.transform(mMax))); + } + } + + } + + private void notifyProgress(int value, boolean fromUser) { + if (mPublicChangeListener != null) { + mPublicChangeListener.onProgressChanged(DiscreteSeekBar.this, value, fromUser); + } + onValueChanged(value); + } + + private void notifyBubble(boolean open) { + if (open) { + onShowBubble(); + } else { + onHideBubble(); + } + } + + /** + * When the {@link DiscreteSeekBar} enters pressed or focused state + * the bubble with the value will be shown, and this method called + *

+ * Subclasses may override this to add functionality around this event + *

+ */ + protected void onShowBubble() { + } + + /** + * When the {@link DiscreteSeekBar} exits pressed or focused state + * the bubble with the value will be hidden, and this method called + *

+ * Subclasses may override this to add functionality around this event + *

+ */ + protected void onHideBubble() { + } + + /** + * When the {@link DiscreteSeekBar} value changes this method is called + *

+ * Subclasses may override this to add functionality around this event + * without having to specify a {@link DiscreteSeekBar.OnProgressChangeListener} + *

+ */ + protected void onValueChanged(int value) { + } + + private void updateKeyboardRange() { + int range = mMax - mMin; + if ((mKeyProgressIncrement == 0) || (range / mKeyProgressIncrement > 20)) { + // It will take the user too long to change this via keys, change it + // to something more reasonable + mKeyProgressIncrement = Math.max(1, Math.round((float) range / 20)); + } + } + + + @Override + protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { + int widthSize = MeasureSpec.getSize(widthMeasureSpec); + int height = mThumb.getIntrinsicHeight() + getPaddingTop() + getPaddingBottom(); + height += (mAddedTouchBounds * 2); + setMeasuredDimension(widthSize, height); + } + + @Override + protected void onLayout(boolean changed, int left, int top, int right, int bottom) { + super.onLayout(changed, left, top, right, bottom); + if (changed) { + removeCallbacks(mShowIndicatorRunnable); + if (!isInEditMode()) { + mIndicator.dismissComplete(); + } + updateFromDrawableState(); + } + } + + @Override + public void scheduleDrawable(Drawable who, Runnable what, long when) { + super.scheduleDrawable(who, what, when); + } + + @Override + protected void onSizeChanged(int w, int h, int oldw, int oldh) { + super.onSizeChanged(w, h, oldw, oldh); + int thumbWidth = mThumb.getIntrinsicWidth(); + int thumbHeight = mThumb.getIntrinsicHeight(); + int addedThumb = mAddedTouchBounds; + int halfThumb = thumbWidth / 2; + int paddingLeft = getPaddingLeft() + addedThumb; + int paddingRight = getPaddingRight(); + int bottom = getHeight() - getPaddingBottom() - addedThumb; + mThumb.setBounds(paddingLeft, bottom - thumbHeight, paddingLeft + thumbWidth, bottom); + int trackHeight = Math.max(mTrackHeight / 2, 1); + mTrack.setBounds(paddingLeft + halfThumb, bottom - halfThumb - trackHeight, + getWidth() - halfThumb - paddingRight - addedThumb, bottom - halfThumb + trackHeight); + int scrubberHeight = Math.max(mScrubberHeight / 2, 2); + mScrubber.setBounds(paddingLeft + halfThumb, bottom - halfThumb - scrubberHeight, + paddingLeft + halfThumb, bottom - halfThumb + scrubberHeight); + + //Update the thumb position after size changed + updateThumbPosFromCurrentProgress(); + } + + @Override + protected synchronized void onDraw(Canvas canvas) { + if (!isLollipopOrGreater) { + mRipple.draw(canvas); + } + super.onDraw(canvas); + mTrack.draw(canvas); + mScrubber.draw(canvas); + mThumb.draw(canvas); + } + + @Override + protected void drawableStateChanged() { + super.drawableStateChanged(); + updateFromDrawableState(); + } + + private void updateFromDrawableState() { + int[] state = getDrawableState(); + boolean focused = false; + boolean pressed = false; + for (int i : state) { + if (i == FOCUSED_STATE) { + focused = true; + } else if (i == PRESSED_STATE) { + pressed = true; + } + } + if (isEnabled() && (focused || pressed) && mIndicatorPopupEnabled) { + //We want to add a small delay here to avoid + //poping in/out on simple taps + removeCallbacks(mShowIndicatorRunnable); + postDelayed(mShowIndicatorRunnable, INDICATOR_DELAY_FOR_TAPS); + } else { + hideFloater(); + } + mThumb.setState(state); + mTrack.setState(state); + mScrubber.setState(state); + mRipple.setState(state); + } + + private void updateProgressMessage(int value) { + if (!isInEditMode()) { + if (mNumericTransformer.useStringTransform()) { + mIndicator.setValue(mNumericTransformer.transformToString(value)); + } else { + mIndicator.setValue(convertValueToMessage(mNumericTransformer.transform(value))); + } + } + } + + private String convertValueToMessage(int value) { + String format = mIndicatorFormatter != null ? mIndicatorFormatter : DEFAULT_FORMATTER; + //We're trying to re-use the Formatter here to avoid too much memory allocations + //But I'm not completey sure if it's doing anything good... :( + //Previously, this condition was wrong so the Formatter was always re-created + //But as I fixed the condition, the formatter started outputting trash characters from previous + //calls, so I mark the StringBuilder as empty before calling format again. + + //Anyways, I see the memory usage still go up on every call to this method + //and I have no clue on how to fix that... damn Strings... + if (mFormatter == null || !mFormatter.locale().equals(Locale.getDefault())) { + int bufferSize = format.length() + String.valueOf(mMax).length(); + if (mFormatBuilder == null) { + mFormatBuilder = new StringBuilder(bufferSize); + } else { + mFormatBuilder.ensureCapacity(bufferSize); + } + mFormatter = new Formatter(mFormatBuilder, Locale.getDefault()); + } else { + mFormatBuilder.setLength(0); + } + return mFormatter.format(format, value).toString(); + } + + @Override + public boolean onTouchEvent(MotionEvent event) { + if (!isEnabled()) { + return false; + } + int actionMasked = event.getActionMasked(); + switch (actionMasked) { + case MotionEvent.ACTION_DOWN: + mDownX = event.getX(); + startDragging(event, isInScrollingContainer()); + break; + case MotionEvent.ACTION_MOVE: + if (isDragging()) { + updateDragging(event); + } else { + final float x = event.getX(); + if (Math.abs(x - mDownX) > mTouchSlop) { + startDragging(event, false); + } + } + break; + case MotionEvent.ACTION_UP: + if (!isDragging() && mAllowTrackClick) { + startDragging(event, false); + updateDragging(event); + } + stopDragging(); + break; + case MotionEvent.ACTION_CANCEL: + stopDragging(); + break; + } + return true; + } + + private boolean isInScrollingContainer() { + ViewParent p = getParent(); + while (p instanceof ViewGroup) { + if (((ViewGroup) p).shouldDelayChildPressedState()) { + return true; + } + p = p.getParent(); + } + return false; + } + + private boolean startDragging(MotionEvent ev, boolean ignoreTrackIfInScrollContainer) { + final Rect bounds = mTempRect; + mThumb.copyBounds(bounds); + //Grow the current thumb rect for a bigger touch area + bounds.inset(-mAddedTouchBounds, -mAddedTouchBounds); + mIsDragging = (bounds.contains((int) ev.getX(), (int) ev.getY())); + if (!mIsDragging && mAllowTrackClick && !ignoreTrackIfInScrollContainer) { + //If the user clicked outside the thumb, we compute the current position + //and force an immediate drag to it. + mIsDragging = true; + mDragOffset = (bounds.width() / 2) - mAddedTouchBounds; + updateDragging(ev); + //As the thumb may have moved, get the bounds again + mThumb.copyBounds(bounds); + bounds.inset(-mAddedTouchBounds, -mAddedTouchBounds); + } + if (mIsDragging) { + setPressed(true); + attemptClaimDrag(); + setHotspot(ev.getX(), ev.getY()); + mDragOffset = (int) (ev.getX() - bounds.left - mAddedTouchBounds); + if (mPublicChangeListener != null) { + mPublicChangeListener.onStartTrackingTouch(this); + } + } + return mIsDragging; + } + + private boolean isDragging() { + return mIsDragging; + } + + private void stopDragging() { + if (mPublicChangeListener != null) { + mPublicChangeListener.onStopTrackingTouch(this); + } + mIsDragging = false; + setPressed(false); + } + + @Override + public boolean onKeyDown(int keyCode, KeyEvent event) { + //TODO: Should we reverse the keys for RTL? The framework's SeekBar does NOT.... + boolean handled = false; + if (isEnabled()) { + int progress = getAnimatedProgress(); + switch (keyCode) { + case KeyEvent.KEYCODE_DPAD_LEFT: + handled = true; + if (progress <= mMin) break; + animateSetProgress(progress - mKeyProgressIncrement); + break; + case KeyEvent.KEYCODE_DPAD_RIGHT: + handled = true; + if (progress >= mMax) break; + animateSetProgress(progress + mKeyProgressIncrement); + break; + } + } + + return handled || super.onKeyDown(keyCode, event); + } + + private int getAnimatedProgress() { + return isAnimationRunning() ? getAnimationTarget() : mValue; + } + + + private boolean isAnimationRunning() { + return mPositionAnimator != null && mPositionAnimator.isRunning(); + } + + private void animateSetProgress(int progress) { + final float curProgress = isAnimationRunning() ? getAnimationPosition() : getProgress(); + + if (progress < mMin) { + progress = mMin; + } else if (progress > mMax) { + progress = mMax; + } + //setProgressValueOnly(progress); + + if (mPositionAnimator != null) { + mPositionAnimator.cancel(); + } + + mAnimationTarget = progress; + mPositionAnimator = ValueAnimator.ofFloat(curProgress, progress); + mPositionAnimator.setDuration(PROGRESS_ANIMATION_DURATION); + mPositionAnimator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() { + @Override + public void onAnimationUpdate(ValueAnimator valueAnimator) { + setAnimationPosition((float) valueAnimator.getAnimatedValue()); + } + }); + + mPositionAnimator.start(); + } + + private int getAnimationTarget() { + return mAnimationTarget; + } + + private void setAnimationPosition(float position) { + mAnimationPosition = position; + float currentScale = (position - mMin) / (float) (mMax - mMin); + updateProgressFromAnimation(currentScale); + } + + private float getAnimationPosition() { + return mAnimationPosition; + } + + private void updateDragging(MotionEvent ev) { + setHotspot(ev.getX(), ev.getY()); + int x = (int) ev.getX(); + Rect oldBounds = mThumb.getBounds(); + int halfThumb = oldBounds.width() / 2; + int addedThumb = mAddedTouchBounds; + int newX = x - mDragOffset + halfThumb; + int left = getPaddingLeft() + halfThumb + addedThumb; + int right = getWidth() - (getPaddingRight() + halfThumb + addedThumb); + if (newX < left) { + newX = left; + } else if (newX > right) { + newX = right; + } + + int available = right - left; + float scale = (float) (newX - left) / (float) available; + if (isRtl()) { + scale = 1f - scale; + } + int progress = Math.round((scale * (mMax - mMin)) + mMin); + setProgress(progress, true); + } + + private void updateProgressFromAnimation(float scale) { + Rect bounds = mThumb.getBounds(); + int halfThumb = bounds.width() / 2; + int addedThumb = mAddedTouchBounds; + int left = getPaddingLeft() + halfThumb + addedThumb; + int right = getWidth() - (getPaddingRight() + halfThumb + addedThumb); + int available = right - left; + int progress = Math.round((scale * (mMax - mMin)) + mMin); + //we don't want to just call setProgress here to avoid the animation being cancelled, + //and this position is not bound to a real progress value but interpolated + if (progress != getProgress()) { + mValue = progress; + notifyProgress(mValue, true); + updateProgressMessage(progress); + } + final int thumbPos = (int) (scale * available + 0.5f); + updateThumbPos(thumbPos); + } + + private void updateThumbPosFromCurrentProgress() { + int thumbWidth = mThumb.getIntrinsicWidth(); + int addedThumb = mAddedTouchBounds; + int halfThumb = thumbWidth / 2; + float scaleDraw = (mValue - mMin) / (float) (mMax - mMin); + + //This doesn't matter if RTL, as we just need the "avaiable" area + int left = getPaddingLeft() + halfThumb + addedThumb; + int right = getWidth() - (getPaddingRight() + halfThumb + addedThumb); + int available = right - left; + + final int thumbPos = (int) (scaleDraw * available + 0.5f); + updateThumbPos(thumbPos); + } + + private void updateThumbPos(int posX) { + int thumbWidth = mThumb.getIntrinsicWidth(); + int halfThumb = thumbWidth / 2; + int start; + if (isRtl()) { + start = getWidth() - getPaddingRight() - mAddedTouchBounds; + posX = start - posX - thumbWidth; + } else { + start = getPaddingLeft() + mAddedTouchBounds; + posX = start + posX; + } + mThumb.copyBounds(mInvalidateRect); + mThumb.setBounds(posX, mInvalidateRect.top, posX + thumbWidth, mInvalidateRect.bottom); + if (isRtl()) { + mScrubber.getBounds().right = start - halfThumb; + mScrubber.getBounds().left = posX + halfThumb; + } else { + mScrubber.getBounds().left = start + halfThumb; + mScrubber.getBounds().right = posX + halfThumb; + } + final Rect finalBounds = mTempRect; + mThumb.copyBounds(finalBounds); + if (!isInEditMode()) { + mIndicator.move(finalBounds.centerX()); + } + + mInvalidateRect.inset(-mAddedTouchBounds, -mAddedTouchBounds); + finalBounds.inset(-mAddedTouchBounds, -mAddedTouchBounds); + mInvalidateRect.union(finalBounds); + SeekBarCompat.setHotspotBounds(mRipple, finalBounds.left, finalBounds.top, finalBounds.right, finalBounds.bottom); + invalidate(mInvalidateRect); + } + + + private void setHotspot(float x, float y) { + DrawableCompat.setHotspot(mRipple, x, y); + } + + @Override + protected boolean verifyDrawable(Drawable who) { + return who == mThumb || who == mTrack || who == mScrubber || who == mRipple || super.verifyDrawable(who); + } + + private void attemptClaimDrag() { + ViewParent parent = getParent(); + if (parent != null) { + parent.requestDisallowInterceptTouchEvent(true); + } + } + + private Runnable mShowIndicatorRunnable = new Runnable() { + @Override + public void run() { + showFloater(); + } + }; + + private void showFloater() { + if (!isInEditMode()) { + mThumb.animateToPressed(); + mIndicator.showIndicator(this, mThumb.getBounds()); + notifyBubble(true); + } + } + + private void hideFloater() { + removeCallbacks(mShowIndicatorRunnable); + if (!isInEditMode()) { + mIndicator.dismiss(); + notifyBubble(false); + } + } + + private MarkerDrawable.MarkerAnimationListener mFloaterListener = new MarkerDrawable.MarkerAnimationListener() { + @Override + public void onClosingComplete() { + mThumb.animateToNormal(); + } + + @Override + public void onOpeningComplete() { + } + }; + + @Override + protected void onDetachedFromWindow() { + super.onDetachedFromWindow(); + removeCallbacks(mShowIndicatorRunnable); + if (!isInEditMode()) { + mIndicator.dismissComplete(); + } + } + + public boolean isRtl() { + return (ViewCompat.getLayoutDirection(this) == ViewCompat.LAYOUT_DIRECTION_RTL) && mMirrorForRtl; + } + + @Override + protected Parcelable onSaveInstanceState() { + Parcelable superState = super.onSaveInstanceState(); + CustomState state = new CustomState(superState); + state.progress = getProgress(); + state.max = mMax; + state.min = mMin; + return state; + } + + @Override + protected void onRestoreInstanceState(Parcelable state) { + if (state == null || !state.getClass().equals(CustomState.class)) { + super.onRestoreInstanceState(state); + return; + } + + CustomState customState = (CustomState) state; + setMin(customState.min); + setMax(customState.max); + setProgress(customState.progress, false); + super.onRestoreInstanceState(customState.getSuperState()); + } + + static class CustomState extends BaseSavedState { + private int progress; + private int max; + private int min; + + public CustomState(Parcel source) { + super(source); + progress = source.readInt(); + max = source.readInt(); + min = source.readInt(); + } + + public CustomState(Parcelable superState) { + super(superState); + } + + @Override + public void writeToParcel(Parcel outcoming, int flags) { + super.writeToParcel(outcoming, flags); + outcoming.writeInt(progress); + outcoming.writeInt(max); + outcoming.writeInt(min); + } + + public static final Creator CREATOR = + new Creator() { + + @Override + public CustomState[] newArray(int size) { + return new CustomState[size]; + } + + @Override + public CustomState createFromParcel(Parcel incoming) { + return new CustomState(incoming); + } + }; + } +} diff --git a/library/src/main/java/com/mardous/discreteseekbar/internal/Marker.java b/library/src/main/java/com/mardous/discreteseekbar/internal/Marker.java new file mode 100644 index 0000000..0ce85a9 --- /dev/null +++ b/library/src/main/java/com/mardous/discreteseekbar/internal/Marker.java @@ -0,0 +1,209 @@ +/* + * Copyright (c) Gustavo Claramunt (AnderWeb) 2014. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.mardous.discreteseekbar.internal; + +import android.content.Context; +import android.content.res.ColorStateList; +import android.content.res.TypedArray; +import android.graphics.Canvas; +import android.graphics.drawable.Drawable; +import android.os.Build; +import android.util.AttributeSet; +import android.util.DisplayMetrics; +import android.view.Gravity; +import android.view.View; +import android.view.ViewGroup; +import android.widget.FrameLayout; +import android.widget.TextView; +import androidx.core.view.ViewCompat; +import com.mardous.discreteseekbar.R; +import com.mardous.discreteseekbar.internal.compat.SeekBarCompat; +import com.mardous.discreteseekbar.internal.drawable.MarkerDrawable; + +/** + * {@link android.view.ViewGroup} to be used as the real indicator. + *

+ * I've used this to be able to accommodate the TextView + * and the {@link com.mardous.discreteseekbar.internal.drawable.MarkerDrawable} + * with the required positions and offsets + *

+ * + * @hide + */ +public class Marker extends ViewGroup implements MarkerDrawable.MarkerAnimationListener { + private static final int PADDING_DP = 4; + private static final int ELEVATION_DP = 8; + //The TextView to show the info + private TextView mNumber; + //The max width of this View + private int mWidth; + //some distance between the thumb and our bubble marker. + //This will be added to our measured height + private int mSeparation; + MarkerDrawable mMarkerDrawable; + + public Marker(Context context, AttributeSet attrs, int defStyleAttr, String maxValue, int thumbSize, int separation) { + super(context, attrs, defStyleAttr); + //as we're reading the parent DiscreteSeekBar attributes, it may wrongly set this view's visibility. + setVisibility(View.VISIBLE); + + DisplayMetrics displayMetrics = context.getResources().getDisplayMetrics(); + TypedArray a = context.obtainStyledAttributes(attrs, R.styleable.DiscreteSeekBar, + R.attr.discreteSeekBarStyle, R.style.Widget_DiscreteSeekBar); + + int padding = (int) (PADDING_DP * displayMetrics.density) * 2; + int textAppearanceId = a.getResourceId(R.styleable.DiscreteSeekBar_dsb_indicatorTextAppearance, + R.style.Widget_DiscreteIndicatorTextAppearance); + mNumber = new TextView(context); + //Add some padding to this textView so the bubble has some space to breath + mNumber.setPadding(padding, 0, padding, 0); + mNumber.setTextAppearance(context, textAppearanceId); + mNumber.setGravity(Gravity.CENTER); + mNumber.setText(maxValue); + mNumber.setMaxLines(1); + mNumber.setSingleLine(true); + SeekBarCompat.setTextDirection(mNumber, TEXT_DIRECTION_LOCALE); + mNumber.setVisibility(View.INVISIBLE); + + //add some padding for the elevation shadow not to be clipped + //I'm sure there are better ways of doing this... + setPadding(padding, padding, padding, padding); + + resetSizes(maxValue); + + mSeparation = separation; + ColorStateList color = a.getColorStateList(R.styleable.DiscreteSeekBar_dsb_indicatorColor); + mMarkerDrawable = new MarkerDrawable(color, thumbSize); + mMarkerDrawable.setCallback(this); + mMarkerDrawable.setMarkerListener(this); + mMarkerDrawable.setExternalOffset(padding); + + //Elevation for anroid 5+ + float elevation = a.getDimension(R.styleable.DiscreteSeekBar_dsb_indicatorElevation, ELEVATION_DP * displayMetrics.density); + ViewCompat.setElevation(this, elevation); + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { + SeekBarCompat.setOutlineProvider(this, mMarkerDrawable); + } + a.recycle(); + } + + public void resetSizes(String maxValue) { + DisplayMetrics displayMetrics = getResources().getDisplayMetrics(); + //Account for negative numbers... is there any proper way of getting the biggest string between our range???? + mNumber.setText("-" + maxValue); + //Do a first forced measure call for the TextView (with the biggest text content), + //to calculate the max width and use always the same. + //this avoids the TextView from shrinking and growing when the text content changes + int wSpec = MeasureSpec.makeMeasureSpec(displayMetrics.widthPixels, MeasureSpec.AT_MOST); + int hSpec = MeasureSpec.makeMeasureSpec(displayMetrics.heightPixels, MeasureSpec.AT_MOST); + mNumber.measure(wSpec, hSpec); + mWidth = Math.max(mNumber.getMeasuredWidth(), mNumber.getMeasuredHeight()); + removeView(mNumber); + addView(mNumber, new FrameLayout.LayoutParams(mWidth, mWidth, Gravity.LEFT | Gravity.TOP)); + } + + @Override + protected void dispatchDraw(Canvas canvas) { + mMarkerDrawable.draw(canvas); + super.dispatchDraw(canvas); + } + + @Override + protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { + measureChildren(widthMeasureSpec, heightMeasureSpec); + int widthSize = mWidth + getPaddingLeft() + getPaddingRight(); + int heightSize = mWidth + getPaddingTop() + getPaddingBottom(); + //This diff is the basic calculation of the difference between + //a square side size and its diagonal + //this helps us account for the visual offset created by MarkerDrawable + //when leaving one of the corners un-rounded + int diff = (int) ((1.41f * mWidth) - mWidth) / 2; + setMeasuredDimension(widthSize, heightSize + diff + mSeparation); + } + + @Override + protected void onLayout(boolean changed, int l, int t, int r, int b) { + int left = getPaddingLeft(); + int top = getPaddingTop(); + int right = getWidth() - getPaddingRight(); + int bottom = getHeight() - getPaddingBottom(); + //the TetView is always layout at the top + mNumber.layout(left, top, left + mWidth, top + mWidth); + //the MarkerDrawable uses the whole view, it will adapt itself... + // or it seems so... + mMarkerDrawable.setBounds(left, top, right, bottom); + } + + @Override + protected boolean verifyDrawable(Drawable who) { + return who == mMarkerDrawable || super.verifyDrawable(who); + } + + @Override + protected void onAttachedToWindow() { + super.onAttachedToWindow(); + //HACK: Sometimes, the animateOpen() call is made before the View is attached + //so the drawable cannot schedule itself to run the animation + //I think we can call it here safely. + //I've seen it happen in android 2.3.7 + animateOpen(); + } + + public void setValue(CharSequence value) { + mNumber.setText(value); + } + + public CharSequence getValue() { + return mNumber.getText(); + } + + public void animateOpen() { + mMarkerDrawable.stop(); + mMarkerDrawable.animateToPressed(); + } + + public void animateClose() { + mMarkerDrawable.stop(); + mNumber.setVisibility(View.INVISIBLE); + mMarkerDrawable.animateToNormal(); + } + + @Override + public void onOpeningComplete() { + mNumber.setVisibility(View.VISIBLE); + if (getParent() instanceof MarkerDrawable.MarkerAnimationListener) { + ((MarkerDrawable.MarkerAnimationListener) getParent()).onOpeningComplete(); + } + } + + @Override + public void onClosingComplete() { + if (getParent() instanceof MarkerDrawable.MarkerAnimationListener) { + ((MarkerDrawable.MarkerAnimationListener) getParent()).onClosingComplete(); + } + } + + @Override + protected void onDetachedFromWindow() { + super.onDetachedFromWindow(); + mMarkerDrawable.stop(); + } + + public void setColors(int startColor, int endColor) { + mMarkerDrawable.setColors(startColor, endColor); + } +} diff --git a/library/src/main/java/com/mardous/discreteseekbar/internal/PopupIndicator.java b/library/src/main/java/com/mardous/discreteseekbar/internal/PopupIndicator.java new file mode 100644 index 0000000..1719ae1 --- /dev/null +++ b/library/src/main/java/com/mardous/discreteseekbar/internal/PopupIndicator.java @@ -0,0 +1,267 @@ +/* + * Copyright (c) Gustavo Claramunt (AnderWeb) 2014. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.mardous.discreteseekbar.internal; + +import android.content.Context; +import android.graphics.PixelFormat; +import android.graphics.Point; +import android.graphics.Rect; +import android.os.IBinder; +import android.util.AttributeSet; +import android.util.DisplayMetrics; +import android.view.Gravity; +import android.view.View; +import android.view.ViewGroup; +import android.view.WindowManager; +import android.widget.FrameLayout; +import androidx.core.view.GravityCompat; +import com.mardous.discreteseekbar.internal.drawable.MarkerDrawable; + +/** + * Class to manage the floating bubble thing, similar (but quite worse tested than {@link android.widget.PopupWindow} + *

+ *

+ * This will attach a View to the Window (full-width, measured-height, positioned just under the thumb) + *

+ * + * @hide + * @see #showIndicator(android.view.View, android.graphics.Rect) + * @see #dismiss() + * @see #dismissComplete() + * @see com.mardous.discreteseekbar.internal.PopupIndicator.Floater + */ +public class PopupIndicator { + + private final WindowManager mWindowManager; + private boolean mShowing; + private Floater mPopupView; + //Outside listener for the DiscreteSeekBar to get MarkerDrawable animation events. + //The whole chain of events goes this way: + //MarkerDrawable->Marker->Floater->mListener->DiscreteSeekBar.... + //... phew! + private MarkerDrawable.MarkerAnimationListener mListener; + private int[] mDrawingLocation = new int[2]; + Point screenSize = new Point(); + + public PopupIndicator(Context context, AttributeSet attrs, int defStyleAttr, String maxValue, int thumbSize, int separation) { + mWindowManager = (WindowManager) context.getSystemService(Context.WINDOW_SERVICE); + mPopupView = new Floater(context, attrs, defStyleAttr, maxValue, thumbSize, separation); + } + + public void updateSizes(String maxValue) { + dismissComplete(); + if (mPopupView != null) { + mPopupView.mMarker.resetSizes(maxValue); + } + } + + public void setListener(MarkerDrawable.MarkerAnimationListener listener) { + mListener = listener; + } + + /** + * We want the Floater to be full-width because the contents will be moved from side to side. + * We may/should change this in the future to use just the PARENT View width and/or pass it in the constructor + */ + private void measureFloater() { + int specWidth = View.MeasureSpec.makeMeasureSpec(screenSize.x, View.MeasureSpec.EXACTLY); + int specHeight = View.MeasureSpec.makeMeasureSpec(screenSize.y, View.MeasureSpec.AT_MOST); + mPopupView.measure(specWidth, specHeight); + } + + public void setValue(CharSequence value) { + mPopupView.mMarker.setValue(value); + } + + public boolean isShowing() { + return mShowing; + } + + public void showIndicator(View parent, Rect touchBounds) { + if (isShowing()) { + mPopupView.mMarker.animateOpen(); + return; + } + + IBinder windowToken = parent.getWindowToken(); + if (windowToken != null) { + WindowManager.LayoutParams p = createPopupLayout(windowToken); + + p.gravity = Gravity.TOP | GravityCompat.START; + updateLayoutParamsForPosiion(parent, p, touchBounds.bottom); + mShowing = true; + + translateViewIntoPosition(touchBounds.centerX()); + invokePopup(p); + } + } + + public void move(int x) { + if (!isShowing()) { + return; + } + translateViewIntoPosition(x); + } + + public void setColors(int startColor, int endColor) { + mPopupView.setColors(startColor, endColor); + } + + /** + * This will start the closing animation of the Marker and call onClosingComplete when finished + */ + public void dismiss() { + mPopupView.mMarker.animateClose(); + } + + /** + * FORCE the popup window to be removed. + * You typically calls this when the parent view is being removed from the window to avoid a Window Leak + */ + public void dismissComplete() { + if (isShowing()) { + mShowing = false; + try { + mWindowManager.removeViewImmediate(mPopupView); + } finally { + } + } + } + + private void updateLayoutParamsForPosiion(View anchor, WindowManager.LayoutParams p, int yOffset) { + DisplayMetrics displayMetrics = anchor.getResources().getDisplayMetrics(); + screenSize.set(displayMetrics.widthPixels, displayMetrics.heightPixels); + + measureFloater(); + int measuredHeight = mPopupView.getMeasuredHeight(); + int paddingBottom = mPopupView.mMarker.getPaddingBottom(); + anchor.getLocationInWindow(mDrawingLocation); + p.x = 0; + p.y = mDrawingLocation[1] - measuredHeight + yOffset + paddingBottom; + p.width = screenSize.x; + p.height = measuredHeight; + } + + private void translateViewIntoPosition(final int x) { + mPopupView.setFloatOffset(x + mDrawingLocation[0]); + } + + private void invokePopup(WindowManager.LayoutParams p) { + mWindowManager.addView(mPopupView, p); + mPopupView.mMarker.animateOpen(); + } + + private WindowManager.LayoutParams createPopupLayout(IBinder token) { + WindowManager.LayoutParams p = new WindowManager.LayoutParams(); + p.gravity = Gravity.START | Gravity.TOP; + p.width = ViewGroup.LayoutParams.MATCH_PARENT; + p.height = ViewGroup.LayoutParams.MATCH_PARENT; + p.format = PixelFormat.TRANSLUCENT; + p.flags = computeFlags(p.flags); + p.type = WindowManager.LayoutParams.TYPE_APPLICATION_PANEL; + p.token = token; + p.softInputMode = WindowManager.LayoutParams.SOFT_INPUT_STATE_ALWAYS_HIDDEN; + p.setTitle("DiscreteSeekBar Indicator:" + Integer.toHexString(hashCode())); + + return p; + } + + /** + * I'm NOT completely sure how all this bitwise things work... + * + * @param curFlags + * @return + */ + private int computeFlags(int curFlags) { + curFlags &= ~( + WindowManager.LayoutParams.FLAG_IGNORE_CHEEK_PRESSES | + WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE | + WindowManager.LayoutParams.FLAG_NOT_TOUCHABLE | + WindowManager.LayoutParams.FLAG_WATCH_OUTSIDE_TOUCH | + WindowManager.LayoutParams.FLAG_LAYOUT_NO_LIMITS | + WindowManager.LayoutParams.FLAG_ALT_FOCUSABLE_IM); + curFlags |= WindowManager.LayoutParams.FLAG_IGNORE_CHEEK_PRESSES; + curFlags |= WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE; + curFlags |= WindowManager.LayoutParams.FLAG_NOT_TOUCHABLE; + curFlags |= WindowManager.LayoutParams.FLAG_LAYOUT_NO_LIMITS; + return curFlags; + } + + /** + * Small FrameLayout class to hold and move the bubble around when requested + * I wanted to use the {@link Marker} directly + * but doing so would make some things harder to implement + * (like moving the marker around, having the Marker's outline to work, etc) + */ + private class Floater extends FrameLayout implements MarkerDrawable.MarkerAnimationListener { + private Marker mMarker; + private int mOffset; + + public Floater(Context context, AttributeSet attrs, int defStyleAttr, String maxValue, int thumbSize, int separation) { + super(context); + mMarker = new Marker(context, attrs, defStyleAttr, maxValue, thumbSize, separation); + addView(mMarker, new LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT, Gravity.LEFT | Gravity.TOP)); + } + + @Override + protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { + measureChildren(widthMeasureSpec, heightMeasureSpec); + int widthSize = MeasureSpec.getSize(widthMeasureSpec); + int heightSie = mMarker.getMeasuredHeight(); + setMeasuredDimension(widthSize, heightSie); + } + + @Override + protected void onLayout(boolean changed, int l, int t, int r, int b) { + int centerDiffX = mMarker.getMeasuredWidth() / 2; + int offset = (mOffset - centerDiffX); + mMarker.layout(offset, 0, offset + mMarker.getMeasuredWidth(), mMarker.getMeasuredHeight()); + } + + public void setFloatOffset(int x) { + mOffset = x; + int centerDiffX = mMarker.getMeasuredWidth() / 2; + int offset = (x - centerDiffX); + mMarker.offsetLeftAndRight(offset - mMarker.getLeft()); + //Without hardware acceleration (or API levels<11), offsetting a view seems to NOT invalidate the proper area. + //We should calc the proper invalidate Rect but this will be for now... + if (isHardwareAccelerated()) { + invalidate(); + } + } + + @Override + public void onClosingComplete() { + if (mListener != null) { + mListener.onClosingComplete(); + } + dismissComplete(); + } + + @Override + public void onOpeningComplete() { + if (mListener != null) { + mListener.onOpeningComplete(); + } + } + + public void setColors(int startColor, int endColor) { + mMarker.setColors(startColor, endColor); + } + } + +} diff --git a/library/src/main/java/com/mardous/discreteseekbar/internal/compat/SeekBarCompat.java b/library/src/main/java/com/mardous/discreteseekbar/internal/compat/SeekBarCompat.java new file mode 100644 index 0000000..b66009d --- /dev/null +++ b/library/src/main/java/com/mardous/discreteseekbar/internal/compat/SeekBarCompat.java @@ -0,0 +1,111 @@ +/* + * Copyright (c) Gustavo Claramunt (AnderWeb) 2014. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.mardous.discreteseekbar.internal.compat; + +import android.content.res.ColorStateList; +import android.graphics.drawable.Drawable; +import android.graphics.drawable.RippleDrawable; +import android.os.Build; +import android.view.View; +import android.widget.TextView; +import androidx.annotation.NonNull; +import androidx.core.graphics.drawable.DrawableCompat; +import com.mardous.discreteseekbar.internal.drawable.AlmostRippleDrawable; +import com.mardous.discreteseekbar.internal.drawable.MarkerDrawable; + +/** + * Wrapper compatibility class to call some API-Specific methods + * And offer alternate procedures when possible + * + * @hide + */ +public class SeekBarCompat { + + /** + * Sets the custom Outline provider on API>=21. + * Does nothing on API<21 + * + * @param view + * @param markerDrawable + */ + public static void setOutlineProvider(View view, final MarkerDrawable markerDrawable) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { + SeekBarCompatDontCrash.setOutlineProvider(view, markerDrawable); + } + } + + /** + * Our DiscreteSeekBar implementation uses a circular drawable on API < 21 + * because we don't set it as Background, but draw it ourselves + * + * @param colorStateList + * @return + */ + public static Drawable getRipple(ColorStateList colorStateList) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { + return SeekBarCompatDontCrash.getRipple(colorStateList); + } else { + return new AlmostRippleDrawable(colorStateList); + } + } + + /** + * Sets the color of the seekbar ripple + * @param drawable + * @param colorStateList The ColorStateList the track ripple will be changed to + */ + public static void setRippleColor(@NonNull Drawable drawable, ColorStateList colorStateList) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { + ((RippleDrawable) drawable).setColor(colorStateList); + } else { + ((AlmostRippleDrawable) drawable).setColor(colorStateList); + } + } + + /** + * As our DiscreteSeekBar implementation uses a circular drawable on API < 21 + * we want to use the same method to set its bounds as the Ripple's hotspot bounds. + * + * @param drawable + * @param left + * @param top + * @param right + * @param bottom + */ + public static void setHotspotBounds(Drawable drawable, int left, int top, int right, int bottom) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { + //We don't want the full size rect, Lollipop ripple would be too big + int size = (right - left) / 8; + DrawableCompat.setHotspotBounds(drawable, left + size, top + size, right - size, bottom - size); + } else { + drawable.setBounds(left, top, right, bottom); + } + } + + /** + * Sets the TextView text direction attribute when possible + * + * @param textView + * @param textDirection + * @see android.widget.TextView#setTextDirection(int) + */ + public static void setTextDirection(TextView textView, int textDirection) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN_MR1) { + SeekBarCompatDontCrash.setTextDirection(textView, textDirection); + } + } +} diff --git a/library/src/main/java/com/mardous/discreteseekbar/internal/compat/SeekBarCompatDontCrash.java b/library/src/main/java/com/mardous/discreteseekbar/internal/compat/SeekBarCompatDontCrash.java new file mode 100644 index 0000000..6a9aa37 --- /dev/null +++ b/library/src/main/java/com/mardous/discreteseekbar/internal/compat/SeekBarCompatDontCrash.java @@ -0,0 +1,53 @@ +/* + * Copyright (c) Gustavo Claramunt (AnderWeb) 2014. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.mardous.discreteseekbar.internal.compat; + +import android.annotation.TargetApi; +import android.content.res.ColorStateList; +import android.graphics.Outline; +import android.graphics.drawable.Drawable; +import android.graphics.drawable.RippleDrawable; +import android.view.View; +import android.view.ViewOutlineProvider; +import android.widget.TextView; +import com.mardous.discreteseekbar.internal.drawable.MarkerDrawable; + +/** + * Wrapper compatibility class to call some API-Specific methods + * And offer alternate procedures when possible + * + * @hide + */ +@TargetApi(21) +class SeekBarCompatDontCrash { + public static void setOutlineProvider(View marker, final MarkerDrawable markerDrawable) { + marker.setOutlineProvider(new ViewOutlineProvider() { + @Override + public void getOutline(View view, Outline outline) { + outline.setConvexPath(markerDrawable.getPath()); + } + }); + } + + public static Drawable getRipple(ColorStateList colorStateList) { + return new RippleDrawable(colorStateList, null, null); + } + + public static void setTextDirection(TextView number, int textDirection) { + number.setTextDirection(textDirection); + } +} diff --git a/library/src/main/java/com/mardous/discreteseekbar/internal/drawable/AlmostRippleDrawable.java b/library/src/main/java/com/mardous/discreteseekbar/internal/drawable/AlmostRippleDrawable.java new file mode 100644 index 0000000..1e15d65 --- /dev/null +++ b/library/src/main/java/com/mardous/discreteseekbar/internal/drawable/AlmostRippleDrawable.java @@ -0,0 +1,220 @@ +/* + * Copyright (c) Gustavo Claramunt (AnderWeb) 2014. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.mardous.discreteseekbar.internal.drawable; + +import android.content.res.ColorStateList; +import android.graphics.Canvas; +import android.graphics.Color; +import android.graphics.Paint; +import android.graphics.Rect; +import android.graphics.drawable.Animatable; +import android.os.SystemClock; +import android.view.animation.AccelerateDecelerateInterpolator; +import android.view.animation.Interpolator; +import androidx.annotation.NonNull; + +public class AlmostRippleDrawable extends StateDrawable implements Animatable { + private static final long FRAME_DURATION = 1000 / 60; + private static final int ANIMATION_DURATION = 250; + + private static final float INACTIVE_SCALE = 0f; + private static final float ACTIVE_SCALE = 1f; + private float mCurrentScale = INACTIVE_SCALE; + private Interpolator mInterpolator; + private long mStartTime; + private boolean mReverse = false; + private boolean mRunning = false; + private int mDuration = ANIMATION_DURATION; + private float mAnimationInitialValue; + //We don't use colors just with our drawable state because of animations + private int mPressedColor; + private int mFocusedColor; + private int mDisabledColor; + private int mRippleColor; + private int mRippleBgColor; + + public AlmostRippleDrawable(@NonNull ColorStateList tintStateList) { + super(tintStateList); + mInterpolator = new AccelerateDecelerateInterpolator(); + setColor(tintStateList); + } + + public void setColor(@NonNull ColorStateList tintStateList) { + int defaultColor = tintStateList.getDefaultColor(); + mFocusedColor = tintStateList.getColorForState(new int[]{android.R.attr.state_enabled, android.R.attr.state_focused}, defaultColor); + mPressedColor = tintStateList.getColorForState(new int[]{android.R.attr.state_enabled, android.R.attr.state_pressed}, defaultColor); + mDisabledColor = tintStateList.getColorForState(new int[]{-android.R.attr.state_enabled}, defaultColor); + + //The ripple should be partially transparent + mFocusedColor = getModulatedAlphaColor(130, mFocusedColor); + mPressedColor = getModulatedAlphaColor(130, mPressedColor); + mDisabledColor = getModulatedAlphaColor(130, mDisabledColor); + } + + private static int getModulatedAlphaColor(int alphaValue, int originalColor) { + int alpha = Color.alpha(originalColor); + int scale = alphaValue + (alphaValue >> 7); + alpha = alpha * scale >> 8; + return Color.argb(alpha, Color.red(originalColor), Color.green(originalColor), Color.blue(originalColor)); + } + + @Override + public void doDraw(Canvas canvas, Paint paint) { + Rect bounds = getBounds(); + int size = Math.min(bounds.width(), bounds.height()); + float scale = mCurrentScale; + int rippleColor = mRippleColor; + int bgColor = mRippleBgColor; + float radius = (size / 2); + float radiusAnimated = radius * scale; + if (scale > INACTIVE_SCALE) { + if (bgColor != 0) { + paint.setColor(bgColor); + paint.setAlpha(decreasedAlpha(Color.alpha(bgColor))); + canvas.drawCircle(bounds.centerX(), bounds.centerY(), radius, paint); + } + if (rippleColor != 0) { + paint.setColor(rippleColor); + paint.setAlpha(modulateAlpha(Color.alpha(rippleColor))); + canvas.drawCircle(bounds.centerX(), bounds.centerY(), radiusAnimated, paint); + } + } + } + + private int decreasedAlpha(int alpha) { + int scale = 100 + (100 >> 7); + return alpha * scale >> 8; + } + + @Override + public boolean setState(int[] stateSet) { + int[] oldState = getState(); + boolean oldPressed = false; + for (int i : oldState) { + if (i == android.R.attr.state_pressed) { + oldPressed = true; + } + } + super.setState(stateSet); + boolean focused = false; + boolean pressed = false; + boolean disabled = true; + for (int i : stateSet) { + if (i == android.R.attr.state_focused) { + focused = true; + } else if (i == android.R.attr.state_pressed) { + pressed = true; + } else if (i == android.R.attr.state_enabled) { + disabled = false; + } + } + + if (disabled) { + unscheduleSelf(mUpdater); + mRippleColor = mDisabledColor; + mRippleBgColor = 0; + mCurrentScale = ACTIVE_SCALE / 2; + invalidateSelf(); + } else { + if (pressed) { + animateToPressed(); + mRippleColor = mRippleBgColor = mPressedColor; + } else if (oldPressed) { + mRippleColor = mRippleBgColor = mPressedColor; + animateToNormal(); + } else if (focused) { + mRippleColor = mFocusedColor; + mRippleBgColor = 0; + mCurrentScale = ACTIVE_SCALE; + invalidateSelf(); + } else { + mRippleColor = 0; + mRippleBgColor = 0; + mCurrentScale = INACTIVE_SCALE; + invalidateSelf(); + } + } + return true; + } + + public void animateToPressed() { + unscheduleSelf(mUpdater); + if (mCurrentScale < ACTIVE_SCALE) { + mReverse = false; + mRunning = true; + mAnimationInitialValue = mCurrentScale; + float durationFactor = 1f - ((mAnimationInitialValue - INACTIVE_SCALE) / (ACTIVE_SCALE - INACTIVE_SCALE)); + mDuration = (int) (ANIMATION_DURATION * durationFactor); + mStartTime = SystemClock.uptimeMillis(); + scheduleSelf(mUpdater, mStartTime + FRAME_DURATION); + } + } + + public void animateToNormal() { + unscheduleSelf(mUpdater); + if (mCurrentScale > INACTIVE_SCALE) { + mReverse = true; + mRunning = true; + mAnimationInitialValue = mCurrentScale; + float durationFactor = 1f - ((mAnimationInitialValue - ACTIVE_SCALE) / (INACTIVE_SCALE - ACTIVE_SCALE)); + mDuration = (int) (ANIMATION_DURATION * durationFactor); + mStartTime = SystemClock.uptimeMillis(); + scheduleSelf(mUpdater, mStartTime + FRAME_DURATION); + } + } + + private void updateAnimation(float factor) { + float initial = mAnimationInitialValue; + float destination = mReverse ? INACTIVE_SCALE : ACTIVE_SCALE; + mCurrentScale = initial + (destination - initial) * factor; + invalidateSelf(); + } + + private final Runnable mUpdater = new Runnable() { + + @Override + public void run() { + + long currentTime = SystemClock.uptimeMillis(); + long diff = currentTime - mStartTime; + if (diff < mDuration) { + float interpolation = mInterpolator.getInterpolation((float) diff / (float) mDuration); + scheduleSelf(mUpdater, currentTime + FRAME_DURATION); + updateAnimation(interpolation); + } else { + unscheduleSelf(mUpdater); + mRunning = false; + updateAnimation(1f); + } + } + }; + + @Override + public void start() { + //No-Op. We control our own animation + } + + @Override + public void stop() { + //No-Op. We control our own animation + } + + @Override + public boolean isRunning() { + return mRunning; + } +} diff --git a/library/src/main/java/com/mardous/discreteseekbar/internal/drawable/MarkerDrawable.java b/library/src/main/java/com/mardous/discreteseekbar/internal/drawable/MarkerDrawable.java new file mode 100644 index 0000000..d4095ce --- /dev/null +++ b/library/src/main/java/com/mardous/discreteseekbar/internal/drawable/MarkerDrawable.java @@ -0,0 +1,249 @@ +/* + * Copyright (c) Gustavo Claramunt (AnderWeb) 2014. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.mardous.discreteseekbar.internal.drawable; + +import android.content.res.ColorStateList; +import android.graphics.Canvas; +import android.graphics.Color; +import android.graphics.Matrix; +import android.graphics.Paint; +import android.graphics.Path; +import android.graphics.Rect; +import android.graphics.RectF; +import android.graphics.drawable.Animatable; +import android.os.SystemClock; +import android.view.animation.AccelerateDecelerateInterpolator; +import android.view.animation.Interpolator; +import androidx.annotation.NonNull; + +/** + * Implementation of {@link StateDrawable} to draw a morphing marker symbol. + *

+ * It's basically an implementation of an {@link android.graphics.drawable.Animatable} Drawable with the following details: + *

+ *
    + *
  • Animates from a circle shape to a "marker" shape just using a RoundRect
  • + *
  • Animates color change from the normal state color to the pressed state color
  • + *
  • Uses a {@link android.graphics.Path} to also serve as Outline for API>=21
  • + *
+ * + * @hide + */ +public class MarkerDrawable extends StateDrawable implements Animatable { + private static final long FRAME_DURATION = 1000 / 60; + private static final int ANIMATION_DURATION = 250; + + private float mCurrentScale = 0f; + private Interpolator mInterpolator; + private long mStartTime; + private boolean mReverse = false; + private boolean mRunning = false; + private int mDuration = ANIMATION_DURATION; + //size of the actual thumb drawable to use as circle state size + private float mClosedStateSize; + //value to store que current scale when starting an animation and interpolate from it + private float mAnimationInitialValue; + //extra offset directed from the View to account + //for its internal padding between circle state and marker state + private int mExternalOffset; + //colors for interpolation + private int mStartColor;//Color when the Marker is OPEN + private int mEndColor;//Color when the arker is CLOSED + + Path mPath = new Path(); + RectF mRect = new RectF(); + Matrix mMatrix = new Matrix(); + private MarkerAnimationListener mMarkerListener; + + public MarkerDrawable(@NonNull ColorStateList tintList, int closedSize) { + super(tintList); + mInterpolator = new AccelerateDecelerateInterpolator(); + mClosedStateSize = closedSize; + mStartColor = tintList.getColorForState(new int[]{android.R.attr.state_enabled, android.R.attr.state_pressed}, tintList.getDefaultColor()); + mEndColor = tintList.getDefaultColor(); + + } + + public void setExternalOffset(int offset) { + mExternalOffset = offset; + } + + /** + * The two colors that will be used for the seek thumb. + * + * @param startColor Color used for the seek thumb + * @param endColor Color used for popup indicator + */ + public void setColors(int startColor, int endColor) { + mStartColor = startColor; + mEndColor = endColor; + } + + @Override + void doDraw(Canvas canvas, Paint paint) { + if (!mPath.isEmpty()) { + paint.setStyle(Paint.Style.FILL); + int color = blendColors(mStartColor, mEndColor, mCurrentScale); + paint.setColor(color); + canvas.drawPath(mPath, paint); + } + } + + public Path getPath() { + return mPath; + } + + @Override + protected void onBoundsChange(Rect bounds) { + super.onBoundsChange(bounds); + computePath(bounds); + } + + private void computePath(Rect bounds) { + final float currentScale = mCurrentScale; + final Path path = mPath; + final RectF rect = mRect; + final Matrix matrix = mMatrix; + + path.reset(); + int totalSize = Math.min(bounds.width(), bounds.height()); + + float initial = mClosedStateSize; + float destination = totalSize; + float currentSize = initial + (destination - initial) * currentScale; + + float halfSize = currentSize / 2f; + float inverseScale = 1f - currentScale; + float cornerSize = halfSize * inverseScale; + float[] corners = new float[]{halfSize, halfSize, halfSize, halfSize, halfSize, halfSize, cornerSize, cornerSize}; + rect.set(bounds.left, bounds.top, bounds.left + currentSize, bounds.top + currentSize); + path.addRoundRect(rect, corners, Path.Direction.CCW); + matrix.reset(); + matrix.postRotate(-45, bounds.left + halfSize, bounds.top + halfSize); + matrix.postTranslate((bounds.width() - currentSize) / 2, 0); + float hDiff = (bounds.bottom - currentSize - mExternalOffset) * inverseScale; + matrix.postTranslate(0, hDiff); + path.transform(matrix); + } + + private void updateAnimation(float factor) { + float initial = mAnimationInitialValue; + float destination = mReverse ? 0f : 1f; + mCurrentScale = initial + (destination - initial) * factor; + computePath(getBounds()); + invalidateSelf(); + } + + public void animateToPressed() { + unscheduleSelf(mUpdater); + mReverse = false; + if (mCurrentScale < 1) { + mRunning = true; + mAnimationInitialValue = mCurrentScale; + float durationFactor = 1f - mCurrentScale; + mDuration = (int) (ANIMATION_DURATION * durationFactor); + mStartTime = SystemClock.uptimeMillis(); + scheduleSelf(mUpdater, mStartTime + FRAME_DURATION); + } else { + notifyFinishedToListener(); + } + } + + public void animateToNormal() { + mReverse = true; + unscheduleSelf(mUpdater); + if (mCurrentScale > 0) { + mRunning = true; + mAnimationInitialValue = mCurrentScale; + float durationFactor = 1f - mCurrentScale; + mDuration = ANIMATION_DURATION - (int) (ANIMATION_DURATION * durationFactor); + mStartTime = SystemClock.uptimeMillis(); + scheduleSelf(mUpdater, mStartTime + FRAME_DURATION); + } else { + notifyFinishedToListener(); + } + } + + private final Runnable mUpdater = new Runnable() { + + @Override + public void run() { + + long currentTime = SystemClock.uptimeMillis(); + long diff = currentTime - mStartTime; + if (diff < mDuration) { + float interpolation = mInterpolator.getInterpolation((float) diff / (float) mDuration); + scheduleSelf(mUpdater, currentTime + FRAME_DURATION); + updateAnimation(interpolation); + } else { + unscheduleSelf(mUpdater); + mRunning = false; + updateAnimation(1f); + notifyFinishedToListener(); + } + } + }; + + public void setMarkerListener(MarkerAnimationListener listener) { + mMarkerListener = listener; + } + + private void notifyFinishedToListener() { + if (mMarkerListener != null) { + if (mReverse) { + mMarkerListener.onClosingComplete(); + } else { + mMarkerListener.onOpeningComplete(); + } + } + } + + @Override + public void start() { + //No-Op. We control our own animation + } + + @Override + public void stop() { + unscheduleSelf(mUpdater); + } + + @Override + public boolean isRunning() { + return mRunning; + } + + private static int blendColors(int color1, int color2, float factor) { + final float inverseFactor = 1f - factor; + float a = (Color.alpha(color1) * factor) + (Color.alpha(color2) * inverseFactor); + float r = (Color.red(color1) * factor) + (Color.red(color2) * inverseFactor); + float g = (Color.green(color1) * factor) + (Color.green(color2) * inverseFactor); + float b = (Color.blue(color1) * factor) + (Color.blue(color2) * inverseFactor); + return Color.argb((int) a, (int) r, (int) g, (int) b); + } + + + /** + * A listener interface to porpagate animation events + * This is the "poor's man" AnimatorListener for this Drawable + */ + public interface MarkerAnimationListener { + public void onClosingComplete(); + + public void onOpeningComplete(); + } +} diff --git a/library/src/main/java/com/mardous/discreteseekbar/internal/drawable/StateDrawable.java b/library/src/main/java/com/mardous/discreteseekbar/internal/drawable/StateDrawable.java new file mode 100644 index 0000000..550d4ce --- /dev/null +++ b/library/src/main/java/com/mardous/discreteseekbar/internal/drawable/StateDrawable.java @@ -0,0 +1,119 @@ +/* + * Copyright (c) Gustavo Claramunt (AnderWeb) 2014. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.mardous.discreteseekbar.internal.drawable; + +import android.content.res.ColorStateList; +import android.graphics.Canvas; +import android.graphics.Color; +import android.graphics.ColorFilter; +import android.graphics.Paint; +import android.graphics.PixelFormat; +import android.graphics.drawable.Drawable; +import androidx.annotation.NonNull; + +/** + * A drawable that changes it's Paint color depending on the Drawable State + *

+ * Subclasses should implement {@link #doDraw(android.graphics.Canvas, android.graphics.Paint)} + *

+ * + * @hide + */ +public abstract class StateDrawable extends Drawable { + private ColorStateList mTintStateList; + private final Paint mPaint; + private int mCurrentColor; + private int mAlpha = 255; + + public StateDrawable(@NonNull ColorStateList tintStateList) { + super(); + setColorStateList(tintStateList); + mPaint = new Paint(Paint.ANTI_ALIAS_FLAG); + } + + @Override + public boolean isStateful() { + return (mTintStateList.isStateful()) || super.isStateful(); + } + + @Override + public boolean setState(int[] stateSet) { + boolean handled = super.setState(stateSet); + handled = updateTint(stateSet) || handled; + return handled; + } + + @Override + public int getOpacity() { + return PixelFormat.TRANSLUCENT; + } + + private boolean updateTint(int[] state) { + final int color = mTintStateList.getColorForState(state, mCurrentColor); + if (color != mCurrentColor) { + mCurrentColor = color; + //We've changed states + invalidateSelf(); + return true; + } + return false; + } + + @Override + public void draw(Canvas canvas) { + mPaint.setColor(mCurrentColor); + int alpha = modulateAlpha(Color.alpha(mCurrentColor)); + mPaint.setAlpha(alpha); + doDraw(canvas, mPaint); + } + + public void setColorStateList(@NonNull ColorStateList tintStateList) { + mTintStateList = tintStateList; + mCurrentColor = tintStateList.getDefaultColor(); + } + + /** + * Subclasses should implement this method to do the actual drawing + * + * @param canvas The current {@link android.graphics.Canvas} to draw into + * @param paint The {@link android.graphics.Paint} preconfigurred with the current + * {@link android.content.res.ColorStateList} color + */ + abstract void doDraw(Canvas canvas, Paint paint); + + @Override + public void setAlpha(int alpha) { + mAlpha = alpha; + invalidateSelf(); + } + + int modulateAlpha(int alpha) { + int scale = mAlpha + (mAlpha >> 7); + return alpha * scale >> 8; + } + + @Override + public int getAlpha() { + return mAlpha; + } + + @Override + public void setColorFilter(ColorFilter cf) { + mPaint.setColorFilter(cf); + } + +} diff --git a/library/src/main/java/com/mardous/discreteseekbar/internal/drawable/ThumbDrawable.java b/library/src/main/java/com/mardous/discreteseekbar/internal/drawable/ThumbDrawable.java new file mode 100644 index 0000000..4d000e5 --- /dev/null +++ b/library/src/main/java/com/mardous/discreteseekbar/internal/drawable/ThumbDrawable.java @@ -0,0 +1,108 @@ +/* + * Copyright (c) Gustavo Claramunt (AnderWeb) 2014. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.mardous.discreteseekbar.internal.drawable; + +import android.content.res.ColorStateList; +import android.graphics.Canvas; +import android.graphics.Paint; +import android.graphics.Rect; +import android.graphics.drawable.Animatable; +import android.os.SystemClock; +import androidx.annotation.NonNull; + +/** + *

HACK

+ *

+ * Special {@link com.mardous.discreteseekbar.internal.drawable.StateDrawable} implementation + * to draw the Thumb circle. + *

+ *

+ * It's special because it will stop drawing once the state is pressed/focused BUT only after a small delay. + *

+ *

+ * This special delay is meant to help avoiding frame glitches while the {@link com.mardous.discreteseekbar.internal.Marker} is added to the Window + *

+ * + * @hide + */ +public class ThumbDrawable extends StateDrawable implements Animatable { + //The current size for this drawable. Must be converted to real DPs + public static final int DEFAULT_SIZE_DP = 12; + private final int mSize; + private boolean mOpen; + private boolean mRunning; + + public ThumbDrawable(@NonNull ColorStateList tintStateList, int size) { + super(tintStateList); + mSize = size; + } + + @Override + public int getIntrinsicWidth() { + return mSize; + } + + @Override + public int getIntrinsicHeight() { + return mSize; + } + + @Override + public void doDraw(Canvas canvas, Paint paint) { + if (!mOpen) { + Rect bounds = getBounds(); + float radius = (mSize / 2); + canvas.drawCircle(bounds.centerX(), bounds.centerY(), radius, paint); + } + } + + public void animateToPressed() { + scheduleSelf(opener, SystemClock.uptimeMillis() + 100); + mRunning = true; + } + + public void animateToNormal() { + mOpen = false; + mRunning = false; + unscheduleSelf(opener); + invalidateSelf(); + } + + private Runnable opener = new Runnable() { + @Override + public void run() { + mOpen = true; + invalidateSelf(); + mRunning = false; + } + }; + + @Override + public void start() { + //NOOP + } + + @Override + public void stop() { + animateToNormal(); + } + + @Override + public boolean isRunning() { + return mRunning; + } +} diff --git a/library/src/main/java/com/mardous/discreteseekbar/internal/drawable/TrackOvalDrawable.java b/library/src/main/java/com/mardous/discreteseekbar/internal/drawable/TrackOvalDrawable.java new file mode 100644 index 0000000..0bde7fc --- /dev/null +++ b/library/src/main/java/com/mardous/discreteseekbar/internal/drawable/TrackOvalDrawable.java @@ -0,0 +1,44 @@ +/* + * Copyright (c) Gustavo Claramunt (AnderWeb) 2014. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.mardous.discreteseekbar.internal.drawable; + +import android.content.res.ColorStateList; +import android.graphics.Canvas; +import android.graphics.Paint; +import android.graphics.RectF; +import androidx.annotation.NonNull; + +/** + * Simple {@link com.mardous.discreteseekbar.internal.drawable.StateDrawable} implementation + * to draw circles/ovals + * + * @hide + */ +public class TrackOvalDrawable extends StateDrawable { + private RectF mRectF = new RectF(); + + public TrackOvalDrawable(@NonNull ColorStateList tintStateList) { + super(tintStateList); + } + + @Override + void doDraw(Canvas canvas, Paint paint) { + mRectF.set(getBounds()); + canvas.drawOval(mRectF, paint); + } + +} diff --git a/library/src/main/java/com/mardous/discreteseekbar/internal/drawable/TrackRectDrawable.java b/library/src/main/java/com/mardous/discreteseekbar/internal/drawable/TrackRectDrawable.java new file mode 100644 index 0000000..ba30933 --- /dev/null +++ b/library/src/main/java/com/mardous/discreteseekbar/internal/drawable/TrackRectDrawable.java @@ -0,0 +1,40 @@ +/* + * Copyright (c) Gustavo Claramunt (AnderWeb) 2014. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.mardous.discreteseekbar.internal.drawable; + +import android.content.res.ColorStateList; +import android.graphics.Canvas; +import android.graphics.Paint; +import androidx.annotation.NonNull; + +/** + * Simple {@link com.mardous.discreteseekbar.internal.drawable.StateDrawable} implementation + * to draw rectangles + * + * @hide + */ +public class TrackRectDrawable extends StateDrawable { + public TrackRectDrawable(@NonNull ColorStateList tintStateList) { + super(tintStateList); + } + + @Override + void doDraw(Canvas canvas, Paint paint) { + canvas.drawRect(getBounds(), paint); + } + +} diff --git a/library/src/main/res/color/dsb_progress_color_list.xml b/library/src/main/res/color/dsb_progress_color_list.xml new file mode 100644 index 0000000..bb5d5ac --- /dev/null +++ b/library/src/main/res/color/dsb_progress_color_list.xml @@ -0,0 +1,21 @@ + + + + + + + \ No newline at end of file diff --git a/library/src/main/res/color/dsb_ripple_color_list.xml b/library/src/main/res/color/dsb_ripple_color_list.xml new file mode 100644 index 0000000..fb461a7 --- /dev/null +++ b/library/src/main/res/color/dsb_ripple_color_list.xml @@ -0,0 +1,23 @@ + + + + + + + + + \ No newline at end of file diff --git a/library/src/main/res/color/dsb_track_color_list.xml b/library/src/main/res/color/dsb_track_color_list.xml new file mode 100644 index 0000000..4774f8b --- /dev/null +++ b/library/src/main/res/color/dsb_track_color_list.xml @@ -0,0 +1,21 @@ + + + + + + + \ No newline at end of file diff --git a/library/src/main/res/values/attrs.xml b/library/src/main/res/values/attrs.xml new file mode 100644 index 0000000..9e7229d --- /dev/null +++ b/library/src/main/res/values/attrs.xml @@ -0,0 +1,42 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/library/src/main/res/values/styles.xml b/library/src/main/res/values/styles.xml new file mode 100644 index 0000000..ebe23b8 --- /dev/null +++ b/library/src/main/res/values/styles.xml @@ -0,0 +1,39 @@ + + + + #ff009688 + #ff939393 + #66939393 + #77939393 + #99999999 + + + + + + diff --git a/library/versioning.gradle b/library/versioning.gradle new file mode 100644 index 0000000..2b9e41e --- /dev/null +++ b/library/versioning.gradle @@ -0,0 +1,47 @@ +// From http://www.jayway.com/2015/03/11/automatic-versioncode-generation-in-android-gradle/ +ext { + /** + * Builds an Android version code from the version of the project. + * This is designed to handle the -SNAPSHOT and -RC format. + * + * I.e. during development the version ends with -SNAPSHOT. As the code stabilizes and release + * nears one or many Release Candidates are tagged. These all end with "-RC1", "-RC2" etc. + * And the final release is without any suffix. + * @return + */ + buildVersionCode = { versionString -> + //The rules is as follows: + //-SNAPSHOT counts as 0 + //-RC* counts as the RC number, i.e. 1 to 98 + //final release counts as 99. + + def candidate = "99" + if (versionString.contains('.dirty')) { + // Same as SNAPSHOT + candidate = "0" + } + def lastDash = versionString.lastIndexOf('-'); + def cleaned = versionString.toLowerCase().replaceAll('.dirty', '') + if (lastDash > -1) { + cleaned = cleaned.substring(0, lastDash) + } + def (major, minor, patch) = cleaned.tokenize('.') + if (versionString.contains("+")) { + candidate = "0" + patch = patch.replaceAll("[^0-9]","") + } else { + def rc + (patch, rc) = patch.tokenize("rc") + if (rc) { + candidate = rc + } + } + + (major, minor, patch, candidate) = [major, minor, patch, candidate].collect{it.toInteger()} + + def versionCode = (major * 1000000) + (minor * 10000) + (patch * 100) + candidate; + + return versionCode + // [major, minor, patch, versionCode] + } +} \ No newline at end of file diff --git a/sample/.gitignore b/sample/.gitignore new file mode 100644 index 0000000..796b96d --- /dev/null +++ b/sample/.gitignore @@ -0,0 +1 @@ +/build diff --git a/sample/build.gradle b/sample/build.gradle new file mode 100644 index 0000000..e78b25f --- /dev/null +++ b/sample/build.gradle @@ -0,0 +1,19 @@ +apply plugin: 'com.android.application' + +android { + compileSdkVersion 30 + buildToolsVersion '30.0.0' + + defaultConfig { + applicationId "com.mardous.discreteseekbar.sample" + minSdkVersion 16 + targetSdkVersion 30 + versionCode 1 + versionName "1.0" + } + +} + +dependencies { + compile project (':library') +} diff --git a/sample/src/main/AndroidManifest.xml b/sample/src/main/AndroidManifest.xml new file mode 100644 index 0000000..e6d3ae5 --- /dev/null +++ b/sample/src/main/AndroidManifest.xml @@ -0,0 +1,22 @@ + + + + + + + + + + + + + + diff --git a/sample/src/main/java/com/mardous/discreteseekbar/sample/MainActivity.java b/sample/src/main/java/com/mardous/discreteseekbar/sample/MainActivity.java new file mode 100644 index 0000000..13c625a --- /dev/null +++ b/sample/src/main/java/com/mardous/discreteseekbar/sample/MainActivity.java @@ -0,0 +1,22 @@ +package com.mardous.discreteseekbar.sample; + +import android.app.Activity; +import android.os.Bundle; +import com.mardous.discreteseekbar.DiscreteSeekBar; + +public class MainActivity extends Activity { + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + setContentView(R.layout.activity_main); + DiscreteSeekBar discreteSeekBar1 = findViewById(R.id.discrete1); + discreteSeekBar1.setNumericTransformer(new DiscreteSeekBar.NumericTransformer() { + @Override + public int transform(int value) { + return value * 100; + } + }); + } + +} diff --git a/sample/src/main/res/color/my_floater_color.xml b/sample/src/main/res/color/my_floater_color.xml new file mode 100644 index 0000000..d2b0327 --- /dev/null +++ b/sample/src/main/res/color/my_floater_color.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/sample/src/main/res/color/my_progress_color.xml b/sample/src/main/res/color/my_progress_color.xml new file mode 100644 index 0000000..e635a83 --- /dev/null +++ b/sample/src/main/res/color/my_progress_color.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/sample/src/main/res/drawable-hdpi/ic_launcher.png b/sample/src/main/res/drawable-hdpi/ic_launcher.png new file mode 100644 index 0000000..96a442e Binary files /dev/null and b/sample/src/main/res/drawable-hdpi/ic_launcher.png differ diff --git a/sample/src/main/res/drawable-mdpi/ic_launcher.png b/sample/src/main/res/drawable-mdpi/ic_launcher.png new file mode 100644 index 0000000..359047d Binary files /dev/null and b/sample/src/main/res/drawable-mdpi/ic_launcher.png differ diff --git a/sample/src/main/res/drawable-xhdpi/ic_launcher.png b/sample/src/main/res/drawable-xhdpi/ic_launcher.png new file mode 100644 index 0000000..71c6d76 Binary files /dev/null and b/sample/src/main/res/drawable-xhdpi/ic_launcher.png differ diff --git a/sample/src/main/res/drawable-xxhdpi/ic_launcher.png b/sample/src/main/res/drawable-xxhdpi/ic_launcher.png new file mode 100644 index 0000000..4df1894 Binary files /dev/null and b/sample/src/main/res/drawable-xxhdpi/ic_launcher.png differ diff --git a/sample/src/main/res/layout/activity_main.xml b/sample/src/main/res/layout/activity_main.xml new file mode 100644 index 0000000..3ae5490 --- /dev/null +++ b/sample/src/main/res/layout/activity_main.xml @@ -0,0 +1,65 @@ + + + + + + + + + + + + + + + + diff --git a/sample/src/main/res/values-v11/styles.xml b/sample/src/main/res/values-v11/styles.xml new file mode 100644 index 0000000..65a325d --- /dev/null +++ b/sample/src/main/res/values-v11/styles.xml @@ -0,0 +1,5 @@ + + + + diff --git a/sample/src/main/res/values-v21/styles.xml b/sample/src/main/res/values-v21/styles.xml new file mode 100644 index 0000000..dba3c41 --- /dev/null +++ b/sample/src/main/res/values-v21/styles.xml @@ -0,0 +1,5 @@ + + + + diff --git a/sample/src/main/res/values-w820dp/dimens.xml b/sample/src/main/res/values-w820dp/dimens.xml new file mode 100644 index 0000000..63fc816 --- /dev/null +++ b/sample/src/main/res/values-w820dp/dimens.xml @@ -0,0 +1,6 @@ + + + 64dp + diff --git a/sample/src/main/res/values/dimens.xml b/sample/src/main/res/values/dimens.xml new file mode 100644 index 0000000..47c8224 --- /dev/null +++ b/sample/src/main/res/values/dimens.xml @@ -0,0 +1,5 @@ + + + 16dp + 16dp + diff --git a/sample/src/main/res/values/strings.xml b/sample/src/main/res/values/strings.xml new file mode 100644 index 0000000..eee5d03 --- /dev/null +++ b/sample/src/main/res/values/strings.xml @@ -0,0 +1,7 @@ + + + + DiscreteSeekBar Sample + Bacon ipsum dolor amet chuck alcatra tenderloin brisket filet mignon. Cupim capicola ground round sirloin. Meatball prosciutto pork bresaola biltong, shankle cow drumstick picanha short loin beef ribs pastrami cupim tail. Shank pastrami turkey cow swine tail ham hamburger pork loin pork belly tri-tip cupim. + Brisket pig filet mignon porchetta short ribs, ball tip t-bone meatball alcatra prosciutto. Pork loin pastrami bacon, kevin meatloaf landjaeger ball tip andouille short ribs kielbasa cupim rump. Shoulder alcatra pork t-bone, sausage bacon filet mignon. Chuck pastrami tongue chicken brisket pork ball tip kevin tail frankfurter meatball prosciutto bacon. Venison short loin fatback pig picanha cow kevin tenderloin. Pork cupim leberkas, andouille frankfurter tenderloin meatloaf alcatra biltong.\nMeatloaf tenderloin ground round corned beef shoulder prosciutto biltong tail alcatra landjaeger frankfurter spare ribs. Rump swine capicola, shankle salami chuck sirloin sausage. Tenderloin pig tri-tip brisket chicken, ham ribeye short ribs shankle. T-bone turkey short loin jerky, porchetta turducken tail sausage biltong pork pork belly. Strip steak brisket pork loin salami, tail alcatra turkey kevin prosciutto jerky cupim ground round. Pork belly fatback bresaola porchetta andouille beef alcatra turkey picanha pork chop t-bone swine short loin ham. Short ribs pork swine, shoulder ham frankfurter tri-tip.\nDrumstick swine sausage tri-tip pork bacon. Kevin meatloaf andouille pork, shoulder short ribs capicola. Salami beef shank, shankle spare ribs cow doner leberkas tongue meatball hamburger shoulder. Pork chop ribeye prosciutto ground round brisket. Sirloin pig brisket short ribs corned beef, venison shoulder landjaeger cupim alcatra. Shankle andouille prosciutto, flank turkey venison kielbasa pork chop pork loin. Bresaola tri-tip turducken filet mignon, pork pig cow capicola brisket short ribs doner. + diff --git a/sample/src/main/res/values/styles.xml b/sample/src/main/res/values/styles.xml new file mode 100644 index 0000000..1b72f89 --- /dev/null +++ b/sample/src/main/res/values/styles.xml @@ -0,0 +1,18 @@ + + + + + + + + + diff --git a/settings.gradle b/settings.gradle new file mode 100644 index 0000000..77c36d0 --- /dev/null +++ b/settings.gradle @@ -0,0 +1 @@ +include ':library', ':sample'