diff --git a/.idea/.name b/.idea/.name
new file mode 100644
index 0000000..b30768d
--- /dev/null
+++ b/.idea/.name
@@ -0,0 +1 @@
+InstanceDownload
\ No newline at end of file
diff --git a/.idea/deploymentTargetSelector.xml b/.idea/deploymentTargetSelector.xml
new file mode 100644
index 0000000..b268ef3
--- /dev/null
+++ b/.idea/deploymentTargetSelector.xml
@@ -0,0 +1,10 @@
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/gradle.xml b/.idea/gradle.xml
index 0897082..66f082d 100644
--- a/.idea/gradle.xml
+++ b/.idea/gradle.xml
@@ -10,6 +10,8 @@
+
+
diff --git a/.idea/material_theme_project_new.xml b/.idea/material_theme_project_new.xml
new file mode 100644
index 0000000..60f520a
--- /dev/null
+++ b/.idea/material_theme_project_new.xml
@@ -0,0 +1,13 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/vcs.xml b/.idea/vcs.xml
index 94a25f7..35eb1dd 100644
--- a/.idea/vcs.xml
+++ b/.idea/vcs.xml
@@ -1,6 +1,6 @@
-
+
\ No newline at end of file
diff --git a/app-debug.apk b/app-debug.apk
new file mode 100644
index 0000000..9f47308
Binary files /dev/null and b/app-debug.apk differ
diff --git a/app/build.gradle b/app/build.gradle
index 1f15f7f..25585ee 100644
--- a/app/build.gradle
+++ b/app/build.gradle
@@ -78,14 +78,14 @@ dependencies {
implementation 'com.google.android.exoplayer:exoplayer:2.19.1'
//Image cropper
- implementation 'com.theartofdev.edmodo:android-image-cropper:2.8.0'
+ api project(':cropper')
//Font library
implementation 'io.github.inflationx:calligraphy3:3.1.1'
implementation 'io.github.inflationx:viewpump:2.0.3'
//Page indicator view
- implementation 'com.romandanylyk:pageindicatorview:1.0.3'
+ implementation project(':pageindicatorview')
implementation 'androidx.multidex:multidex:2.0.1'
implementation 'commons-io:commons-io:2.13.0'
diff --git a/app/src/main/java/com/app/instancedownload/ui/aboutus/activity/AboutUsActivity.kt b/app/src/main/java/com/app/instancedownload/ui/aboutus/activity/AboutUsActivity.kt
index fc79820..a69133f 100644
--- a/app/src/main/java/com/app/instancedownload/ui/aboutus/activity/AboutUsActivity.kt
+++ b/app/src/main/java/com/app/instancedownload/ui/aboutus/activity/AboutUsActivity.kt
@@ -26,7 +26,7 @@ class AboutUsActivity : AppCompatActivity() {
super.attachBaseContext(ViewPumpContextWrapper.wrap(newBase))
}
- @androidx.annotation.OptIn(BuildCompat.PrereleaseSdkCheck::class)
+// @androidx.annotation.OptIn(BuildCompat.PrereleaseSdkCheck::class)
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
diff --git a/app/src/main/java/com/app/instancedownload/ui/information/activity/InformationActivity.kt b/app/src/main/java/com/app/instancedownload/ui/information/activity/InformationActivity.kt
index 091dbfb..2e774c4 100644
--- a/app/src/main/java/com/app/instancedownload/ui/information/activity/InformationActivity.kt
+++ b/app/src/main/java/com/app/instancedownload/ui/information/activity/InformationActivity.kt
@@ -30,7 +30,7 @@ class InformationActivity : AppCompatActivity() {
super.attachBaseContext(ViewPumpContextWrapper.wrap(newBase))
}
- @androidx.annotation.OptIn(BuildCompat.PrereleaseSdkCheck::class)
+// @androidx.annotation.OptIn(BuildCompat.PrereleaseSdkCheck::class)
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = DataBindingUtil.setContentView(this, R.layout.activity_information)
diff --git a/app/src/main/java/com/app/instancedownload/ui/videoplayer/activity/VideoPlayerActivity.kt b/app/src/main/java/com/app/instancedownload/ui/videoplayer/activity/VideoPlayerActivity.kt
index 40eb753..2d4d3a4 100644
--- a/app/src/main/java/com/app/instancedownload/ui/videoplayer/activity/VideoPlayerActivity.kt
+++ b/app/src/main/java/com/app/instancedownload/ui/videoplayer/activity/VideoPlayerActivity.kt
@@ -36,7 +36,7 @@ class VideoPlayerActivity : AppCompatActivity() {
private lateinit var player: ExoPlayer
- @androidx.annotation.OptIn(BuildCompat.PrereleaseSdkCheck::class)
+// @androidx.annotation.OptIn(BuildCompat.PrereleaseSdkCheck::class)
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
diff --git a/cropper/.gitignore b/cropper/.gitignore
new file mode 100644
index 0000000..42afabf
--- /dev/null
+++ b/cropper/.gitignore
@@ -0,0 +1 @@
+/build
\ No newline at end of file
diff --git a/cropper/build.gradle b/cropper/build.gradle
new file mode 100644
index 0000000..8d68e21
--- /dev/null
+++ b/cropper/build.gradle
@@ -0,0 +1,42 @@
+plugins {
+ id 'com.android.library'
+ id 'org.jetbrains.kotlin.android'
+}
+
+android {
+ namespace 'com.theartofdev.edmodo.cropper'
+ compileSdk 34
+
+ defaultConfig {
+ minSdk 21
+
+ testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
+ consumerProguardFiles "consumer-rules.pro"
+ }
+
+ buildTypes {
+ release {
+ minifyEnabled false
+ proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
+ }
+ }
+ compileOptions {
+ sourceCompatibility JavaVersion.VERSION_1_8
+ targetCompatibility JavaVersion.VERSION_1_8
+ }
+ kotlinOptions {
+ jvmTarget = '1.8'
+ }
+}
+
+dependencies {
+
+ implementation 'androidx.core:core-ktx:1.13.1'
+ implementation 'androidx.appcompat:appcompat:1.7.0'
+ implementation 'com.google.android.material:material:1.12.0'
+ testImplementation 'junit:junit:4.13.2'
+ androidTestImplementation 'androidx.test.ext:junit:1.1.5'
+ androidTestImplementation 'androidx.test.espresso:espresso-core:3.6.1'
+
+ implementation "androidx.exifinterface:exifinterface:1.3.7"
+}
\ No newline at end of file
diff --git a/cropper/consumer-rules.pro b/cropper/consumer-rules.pro
new file mode 100644
index 0000000..e69de29
diff --git a/cropper/proguard-rules.pro b/cropper/proguard-rules.pro
new file mode 100644
index 0000000..481bb43
--- /dev/null
+++ b/cropper/proguard-rules.pro
@@ -0,0 +1,21 @@
+# Add project specific ProGuard rules here.
+# You can control the set of applied configuration files using the
+# proguardFiles setting in build.gradle.
+#
+# For more details, see
+# http://developer.android.com/guide/developing/tools/proguard.html
+
+# If your project uses WebView with JS, uncomment the following
+# and specify the fully qualified class name to the JavaScript interface
+# class:
+#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
+# public *;
+#}
+
+# Uncomment this to preserve the line number information for
+# debugging stack traces.
+#-keepattributes SourceFile,LineNumberTable
+
+# If you keep the line number information, uncomment this to
+# hide the original source file name.
+#-renamesourcefileattribute SourceFile
\ No newline at end of file
diff --git a/cropper/src/androidTest/java/com/theartofdev/edmodo/cropper/ExampleInstrumentedTest.kt b/cropper/src/androidTest/java/com/theartofdev/edmodo/cropper/ExampleInstrumentedTest.kt
new file mode 100644
index 0000000..0f749c4
--- /dev/null
+++ b/cropper/src/androidTest/java/com/theartofdev/edmodo/cropper/ExampleInstrumentedTest.kt
@@ -0,0 +1,24 @@
+package com.theartofdev.edmodo.cropper
+
+import androidx.test.platform.app.InstrumentationRegistry
+import androidx.test.ext.junit.runners.AndroidJUnit4
+
+import org.junit.Test
+import org.junit.runner.RunWith
+
+import org.junit.Assert.*
+
+/**
+ * Instrumented test, which will execute on an Android device.
+ *
+ * See [testing documentation](http://d.android.com/tools/testing).
+ */
+@RunWith(AndroidJUnit4::class)
+class ExampleInstrumentedTest {
+ @Test
+ fun useAppContext() {
+ // Context of the app under test.
+ val appContext = InstrumentationRegistry.getInstrumentation().targetContext
+ assertEquals("com.theartofdev.edmodo.cropper.test", appContext.packageName)
+ }
+}
\ No newline at end of file
diff --git a/cropper/src/main/AndroidManifest.xml b/cropper/src/main/AndroidManifest.xml
new file mode 100644
index 0000000..a5918e6
--- /dev/null
+++ b/cropper/src/main/AndroidManifest.xml
@@ -0,0 +1,4 @@
+
+
+
+
\ No newline at end of file
diff --git a/cropper/src/main/java/com/theartofdev/edmodo/cropper/BitmapCroppingWorkerTask.java b/cropper/src/main/java/com/theartofdev/edmodo/cropper/BitmapCroppingWorkerTask.java
new file mode 100644
index 0000000..6c6723d
--- /dev/null
+++ b/cropper/src/main/java/com/theartofdev/edmodo/cropper/BitmapCroppingWorkerTask.java
@@ -0,0 +1,300 @@
+// "Therefore those skilled at the unorthodox
+// are infinite as heaven and earth,
+// inexhaustible as the great rivers.
+// When they come to an end,
+// they begin again,
+// like the days and months;
+// they die and are reborn,
+// like the four seasons."
+//
+// - Sun Tsu,
+// "The Art of War"
+
+package com.theartofdev.edmodo.cropper;
+
+import android.content.Context;
+import android.graphics.Bitmap;
+import android.net.Uri;
+import android.os.AsyncTask;
+
+import java.lang.ref.WeakReference;
+
+/** Task to crop bitmap asynchronously from the UI thread. */
+final class BitmapCroppingWorkerTask
+ extends AsyncTask {
+
+ // region: Fields and Consts
+
+ /** Use a WeakReference to ensure the ImageView can be garbage collected */
+ private final WeakReference mCropImageViewReference;
+
+ /** the bitmap to crop */
+ private final Bitmap mBitmap;
+
+ /** The Android URI of the image to load */
+ private final Uri mUri;
+
+ /** The context of the crop image view widget used for loading of bitmap by Android URI */
+ private final Context mContext;
+
+ /** Required cropping 4 points (x0,y0,x1,y1,x2,y2,x3,y3) */
+ private final float[] mCropPoints;
+
+ /** Degrees the image was rotated after loading */
+ private final int mDegreesRotated;
+
+ /** the original width of the image to be cropped (for image loaded from URI) */
+ private final int mOrgWidth;
+
+ /** the original height of the image to be cropped (for image loaded from URI) */
+ private final int mOrgHeight;
+
+ /** is there is fixed aspect ratio for the crop rectangle */
+ private final boolean mFixAspectRatio;
+
+ /** the X aspect ration of the crop rectangle */
+ private final int mAspectRatioX;
+
+ /** the Y aspect ration of the crop rectangle */
+ private final int mAspectRatioY;
+
+ /** required width of the cropping image */
+ private final int mReqWidth;
+
+ /** required height of the cropping image */
+ private final int mReqHeight;
+
+ /** is the image flipped horizontally */
+ private final boolean mFlipHorizontally;
+
+ /** is the image flipped vertically */
+ private final boolean mFlipVertically;
+
+ /** The option to handle requested width/height */
+ private final CropImageView.RequestSizeOptions mReqSizeOptions;
+
+ /** the Android Uri to save the cropped image to */
+ private final Uri mSaveUri;
+
+ /** the compression format to use when writing the image */
+ private final Bitmap.CompressFormat mSaveCompressFormat;
+
+ /** the quality (if applicable) to use when writing the image (0 - 100) */
+ private final int mSaveCompressQuality;
+ // endregion
+
+ BitmapCroppingWorkerTask(
+ CropImageView cropImageView,
+ Bitmap bitmap,
+ float[] cropPoints,
+ int degreesRotated,
+ boolean fixAspectRatio,
+ int aspectRatioX,
+ int aspectRatioY,
+ int reqWidth,
+ int reqHeight,
+ boolean flipHorizontally,
+ boolean flipVertically,
+ CropImageView.RequestSizeOptions options,
+ Uri saveUri,
+ Bitmap.CompressFormat saveCompressFormat,
+ int saveCompressQuality) {
+
+ mCropImageViewReference = new WeakReference<>(cropImageView);
+ mContext = cropImageView.getContext();
+ mBitmap = bitmap;
+ mCropPoints = cropPoints;
+ mUri = null;
+ mDegreesRotated = degreesRotated;
+ mFixAspectRatio = fixAspectRatio;
+ mAspectRatioX = aspectRatioX;
+ mAspectRatioY = aspectRatioY;
+ mReqWidth = reqWidth;
+ mReqHeight = reqHeight;
+ mFlipHorizontally = flipHorizontally;
+ mFlipVertically = flipVertically;
+ mReqSizeOptions = options;
+ mSaveUri = saveUri;
+ mSaveCompressFormat = saveCompressFormat;
+ mSaveCompressQuality = saveCompressQuality;
+ mOrgWidth = 0;
+ mOrgHeight = 0;
+ }
+
+ BitmapCroppingWorkerTask(
+ CropImageView cropImageView,
+ Uri uri,
+ float[] cropPoints,
+ int degreesRotated,
+ int orgWidth,
+ int orgHeight,
+ boolean fixAspectRatio,
+ int aspectRatioX,
+ int aspectRatioY,
+ int reqWidth,
+ int reqHeight,
+ boolean flipHorizontally,
+ boolean flipVertically,
+ CropImageView.RequestSizeOptions options,
+ Uri saveUri,
+ Bitmap.CompressFormat saveCompressFormat,
+ int saveCompressQuality) {
+
+ mCropImageViewReference = new WeakReference<>(cropImageView);
+ mContext = cropImageView.getContext();
+ mUri = uri;
+ mCropPoints = cropPoints;
+ mDegreesRotated = degreesRotated;
+ mFixAspectRatio = fixAspectRatio;
+ mAspectRatioX = aspectRatioX;
+ mAspectRatioY = aspectRatioY;
+ mOrgWidth = orgWidth;
+ mOrgHeight = orgHeight;
+ mReqWidth = reqWidth;
+ mReqHeight = reqHeight;
+ mFlipHorizontally = flipHorizontally;
+ mFlipVertically = flipVertically;
+ mReqSizeOptions = options;
+ mSaveUri = saveUri;
+ mSaveCompressFormat = saveCompressFormat;
+ mSaveCompressQuality = saveCompressQuality;
+ mBitmap = null;
+ }
+
+ /** The Android URI that this task is currently loading. */
+ public Uri getUri() {
+ return mUri;
+ }
+
+ /**
+ * Crop image in background.
+ *
+ * @param params ignored
+ * @return the decoded bitmap data
+ */
+ @Override
+ protected BitmapCroppingWorkerTask.Result doInBackground(Void... params) {
+ try {
+ if (!isCancelled()) {
+
+ BitmapUtils.BitmapSampled bitmapSampled;
+ if (mUri != null) {
+ bitmapSampled =
+ BitmapUtils.cropBitmap(
+ mContext,
+ mUri,
+ mCropPoints,
+ mDegreesRotated,
+ mOrgWidth,
+ mOrgHeight,
+ mFixAspectRatio,
+ mAspectRatioX,
+ mAspectRatioY,
+ mReqWidth,
+ mReqHeight,
+ mFlipHorizontally,
+ mFlipVertically);
+ } else if (mBitmap != null) {
+ bitmapSampled =
+ BitmapUtils.cropBitmapObjectHandleOOM(
+ mBitmap,
+ mCropPoints,
+ mDegreesRotated,
+ mFixAspectRatio,
+ mAspectRatioX,
+ mAspectRatioY,
+ mFlipHorizontally,
+ mFlipVertically);
+ } else {
+ return new Result((Bitmap) null, 1);
+ }
+
+ Bitmap bitmap =
+ BitmapUtils.resizeBitmap(bitmapSampled.bitmap, mReqWidth, mReqHeight, mReqSizeOptions);
+
+ if (mSaveUri == null) {
+ return new Result(bitmap, bitmapSampled.sampleSize);
+ } else {
+ BitmapUtils.writeBitmapToUri(
+ mContext, bitmap, mSaveUri, mSaveCompressFormat, mSaveCompressQuality);
+ if (bitmap != null) {
+ bitmap.recycle();
+ }
+ return new Result(mSaveUri, bitmapSampled.sampleSize);
+ }
+ }
+ return null;
+ } catch (Exception e) {
+ return new Result(e, mSaveUri != null);
+ }
+ }
+
+ /**
+ * Once complete, see if ImageView is still around and set bitmap.
+ *
+ * @param result the result of bitmap cropping
+ */
+ @Override
+ protected void onPostExecute(Result result) {
+ if (result != null) {
+ boolean completeCalled = false;
+ if (!isCancelled()) {
+ CropImageView cropImageView = mCropImageViewReference.get();
+ if (cropImageView != null) {
+ completeCalled = true;
+ cropImageView.onImageCroppingAsyncComplete(result);
+ }
+ }
+ if (!completeCalled && result.bitmap != null) {
+ // fast release of unused bitmap
+ result.bitmap.recycle();
+ }
+ }
+ }
+
+ // region: Inner class: Result
+
+ /** The result of BitmapCroppingWorkerTask async loading. */
+ static final class Result {
+
+ /** The cropped bitmap */
+ public final Bitmap bitmap;
+
+ /** The saved cropped bitmap uri */
+ public final Uri uri;
+
+ /** The error that occurred during async bitmap cropping. */
+ final Exception error;
+
+ /** is the cropping request was to get a bitmap or to save it to uri */
+ final boolean isSave;
+
+ /** sample size used creating the crop bitmap to lower its size */
+ final int sampleSize;
+
+ Result(Bitmap bitmap, int sampleSize) {
+ this.bitmap = bitmap;
+ this.uri = null;
+ this.error = null;
+ this.isSave = false;
+ this.sampleSize = sampleSize;
+ }
+
+ Result(Uri uri, int sampleSize) {
+ this.bitmap = null;
+ this.uri = uri;
+ this.error = null;
+ this.isSave = true;
+ this.sampleSize = sampleSize;
+ }
+
+ Result(Exception error, boolean isSave) {
+ this.bitmap = null;
+ this.uri = null;
+ this.error = error;
+ this.isSave = isSave;
+ this.sampleSize = 1;
+ }
+ }
+ // endregion
+}
diff --git a/cropper/src/main/java/com/theartofdev/edmodo/cropper/BitmapLoadingWorkerTask.java b/cropper/src/main/java/com/theartofdev/edmodo/cropper/BitmapLoadingWorkerTask.java
new file mode 100644
index 0000000..683d2ae
--- /dev/null
+++ b/cropper/src/main/java/com/theartofdev/edmodo/cropper/BitmapLoadingWorkerTask.java
@@ -0,0 +1,150 @@
+// "Therefore those skilled at the unorthodox
+// are infinite as heaven and earth,
+// inexhaustible as the great rivers.
+// When they come to an end,
+// they begin again,
+// like the days and months;
+// they die and are reborn,
+// like the four seasons."
+//
+// - Sun Tsu,
+// "The Art of War"
+
+package com.theartofdev.edmodo.cropper;
+
+import android.content.Context;
+import android.graphics.Bitmap;
+import android.net.Uri;
+import android.os.AsyncTask;
+import android.util.DisplayMetrics;
+
+import java.lang.ref.WeakReference;
+
+/** Task to load bitmap asynchronously from the UI thread. */
+final class BitmapLoadingWorkerTask extends AsyncTask {
+
+ // region: Fields and Consts
+
+ /** Use a WeakReference to ensure the ImageView can be garbage collected */
+ private final WeakReference mCropImageViewReference;
+
+ /** The Android URI of the image to load */
+ private final Uri mUri;
+
+ /** The context of the crop image view widget used for loading of bitmap by Android URI */
+ private final Context mContext;
+
+ /** required width of the cropping image after density adjustment */
+ private final int mWidth;
+
+ /** required height of the cropping image after density adjustment */
+ private final int mHeight;
+ // endregion
+
+ public BitmapLoadingWorkerTask(CropImageView cropImageView, Uri uri) {
+ mUri = uri;
+ mCropImageViewReference = new WeakReference<>(cropImageView);
+
+ mContext = cropImageView.getContext();
+
+ DisplayMetrics metrics = cropImageView.getResources().getDisplayMetrics();
+ double densityAdj = metrics.density > 1 ? 1 / metrics.density : 1;
+ mWidth = (int) (metrics.widthPixels * densityAdj);
+ mHeight = (int) (metrics.heightPixels * densityAdj);
+ }
+
+ /** The Android URI that this task is currently loading. */
+ public Uri getUri() {
+ return mUri;
+ }
+
+ /**
+ * Decode image in background.
+ *
+ * @param params ignored
+ * @return the decoded bitmap data
+ */
+ @Override
+ protected Result doInBackground(Void... params) {
+ try {
+ if (!isCancelled()) {
+
+ BitmapUtils.BitmapSampled decodeResult =
+ BitmapUtils.decodeSampledBitmap(mContext, mUri, mWidth, mHeight);
+
+ if (!isCancelled()) {
+
+ BitmapUtils.RotateBitmapResult rotateResult =
+ BitmapUtils.rotateBitmapByExif(decodeResult.bitmap, mContext, mUri);
+
+ return new Result(
+ mUri, rotateResult.bitmap, decodeResult.sampleSize, rotateResult.degrees);
+ }
+ }
+ return null;
+ } catch (Exception e) {
+ return new Result(mUri, e);
+ }
+ }
+
+ /**
+ * Once complete, see if ImageView is still around and set bitmap.
+ *
+ * @param result the result of bitmap loading
+ */
+ @Override
+ protected void onPostExecute(Result result) {
+ if (result != null) {
+ boolean completeCalled = false;
+ if (!isCancelled()) {
+ CropImageView cropImageView = mCropImageViewReference.get();
+ if (cropImageView != null) {
+ completeCalled = true;
+ cropImageView.onSetImageUriAsyncComplete(result);
+ }
+ }
+ if (!completeCalled && result.bitmap != null) {
+ // fast release of unused bitmap
+ result.bitmap.recycle();
+ }
+ }
+ }
+
+ // region: Inner class: Result
+
+ /** The result of BitmapLoadingWorkerTask async loading. */
+ public static final class Result {
+
+ /** The Android URI of the image to load */
+ public final Uri uri;
+
+ /** The loaded bitmap */
+ public final Bitmap bitmap;
+
+ /** The sample size used to load the given bitmap */
+ public final int loadSampleSize;
+
+ /** The degrees the image was rotated */
+ public final int degreesRotated;
+
+ /** The error that occurred during async bitmap loading. */
+ public final Exception error;
+
+ Result(Uri uri, Bitmap bitmap, int loadSampleSize, int degreesRotated) {
+ this.uri = uri;
+ this.bitmap = bitmap;
+ this.loadSampleSize = loadSampleSize;
+ this.degreesRotated = degreesRotated;
+ this.error = null;
+ }
+
+ Result(Uri uri, Exception error) {
+ this.uri = uri;
+ this.bitmap = null;
+ this.loadSampleSize = 0;
+ this.degreesRotated = 0;
+ this.error = error;
+ }
+ }
+ // endregion
+}
diff --git a/cropper/src/main/java/com/theartofdev/edmodo/cropper/BitmapUtils.java b/cropper/src/main/java/com/theartofdev/edmodo/cropper/BitmapUtils.java
new file mode 100644
index 0000000..023043a
--- /dev/null
+++ b/cropper/src/main/java/com/theartofdev/edmodo/cropper/BitmapUtils.java
@@ -0,0 +1,877 @@
+// "Therefore those skilled at the unorthodox
+// are infinite as heaven and earth,
+// inexhaustible as the great rivers.
+// When they come to an end,
+// they begin again,
+// like the days and months;
+// they die and are reborn,
+// like the four seasons."
+//
+// - Sun Tsu,
+// "The Art of War"
+
+package com.theartofdev.edmodo.cropper;
+
+import android.content.ContentResolver;
+import android.content.Context;
+import android.graphics.Bitmap;
+import android.graphics.BitmapFactory;
+import android.graphics.BitmapRegionDecoder;
+import android.graphics.Matrix;
+import android.graphics.Rect;
+import android.graphics.RectF;
+import android.net.Uri;
+import android.util.Log;
+import android.util.Pair;
+
+import java.io.Closeable;
+import java.io.File;
+import java.io.FileNotFoundException;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.lang.ref.WeakReference;
+
+import javax.microedition.khronos.egl.EGL10;
+import javax.microedition.khronos.egl.EGLConfig;
+import javax.microedition.khronos.egl.EGLContext;
+import javax.microedition.khronos.egl.EGLDisplay;
+
+import androidx.exifinterface.media.ExifInterface;
+
+/** Utility class that deals with operations with an ImageView. */
+final class BitmapUtils {
+
+ static final Rect EMPTY_RECT = new Rect();
+
+ static final RectF EMPTY_RECT_F = new RectF();
+
+ /** Reusable rectangle for general internal usage */
+ static final RectF RECT = new RectF();
+
+ /** Reusable point for general internal usage */
+ static final float[] POINTS = new float[6];
+
+ /** Reusable point for general internal usage */
+ static final float[] POINTS2 = new float[6];
+
+ /** Used to know the max texture size allowed to be rendered */
+ private static int mMaxTextureSize;
+
+ /** used to save bitmaps during state save and restore so not to reload them. */
+ static Pair> mStateBitmap;
+
+ /**
+ * Rotate the given image by reading the Exif value of the image (uri).
+ * If no rotation is required the image will not be rotated.
+ * New bitmap is created and the old one is recycled.
+ */
+ static RotateBitmapResult rotateBitmapByExif(Bitmap bitmap, Context context, Uri uri) {
+ ExifInterface ei = null;
+ try {
+ InputStream is = context.getContentResolver().openInputStream(uri);
+ if (is != null) {
+ ei = new ExifInterface(is);
+ is.close();
+ }
+ } catch (Exception ignored) {
+ }
+ return ei != null ? rotateBitmapByExif(bitmap, ei) : new RotateBitmapResult(bitmap, 0);
+ }
+
+ /**
+ * Rotate the given image by given Exif value.
+ * If no rotation is required the image will not be rotated.
+ * New bitmap is created and the old one is recycled.
+ */
+ static RotateBitmapResult rotateBitmapByExif(Bitmap bitmap, ExifInterface exif) {
+ int degrees;
+ int orientation =
+ exif.getAttributeInt(ExifInterface.TAG_ORIENTATION, ExifInterface.ORIENTATION_NORMAL);
+ switch (orientation) {
+ case ExifInterface.ORIENTATION_ROTATE_90:
+ degrees = 90;
+ break;
+ case ExifInterface.ORIENTATION_ROTATE_180:
+ degrees = 180;
+ break;
+ case ExifInterface.ORIENTATION_ROTATE_270:
+ degrees = 270;
+ break;
+ default:
+ degrees = 0;
+ break;
+ }
+ return new RotateBitmapResult(bitmap, degrees);
+ }
+
+ /** Decode bitmap from stream using sampling to get bitmap with the requested limit. */
+ static BitmapSampled decodeSampledBitmap(Context context, Uri uri, int reqWidth, int reqHeight) {
+
+ try {
+ ContentResolver resolver = context.getContentResolver();
+
+ // First decode with inJustDecodeBounds=true to check dimensions
+ BitmapFactory.Options options = decodeImageForOption(resolver, uri);
+
+ if(options.outWidth == -1 && options.outHeight == -1)
+ throw new RuntimeException("File is not a picture");
+
+ // Calculate inSampleSize
+ options.inSampleSize =
+ Math.max(
+ calculateInSampleSizeByReqestedSize(
+ options.outWidth, options.outHeight, reqWidth, reqHeight),
+ calculateInSampleSizeByMaxTextureSize(options.outWidth, options.outHeight));
+
+ // Decode bitmap with inSampleSize set
+ Bitmap bitmap = decodeImage(resolver, uri, options);
+
+ return new BitmapSampled(bitmap, options.inSampleSize);
+
+ } catch (Exception e) {
+ throw new RuntimeException(
+ "Failed to load sampled bitmap: " + uri + "\r\n" + e.getMessage(), e);
+ }
+ }
+
+ /**
+ * Crop image bitmap from given bitmap using the given points in the original bitmap and the given
+ * rotation.
+ * if the rotation is not 0,90,180 or 270 degrees then we must first crop a larger area of the
+ * image that contains the requires rectangle, rotate and then crop again a sub rectangle.
+ * If crop fails due to OOM we scale the cropping image by 0.5 every time it fails until it is
+ * small enough.
+ */
+ static BitmapSampled cropBitmapObjectHandleOOM(
+ Bitmap bitmap,
+ float[] points,
+ int degreesRotated,
+ boolean fixAspectRatio,
+ int aspectRatioX,
+ int aspectRatioY,
+ boolean flipHorizontally,
+ boolean flipVertically) {
+ int scale = 1;
+ while (true) {
+ try {
+ Bitmap cropBitmap =
+ cropBitmapObjectWithScale(
+ bitmap,
+ points,
+ degreesRotated,
+ fixAspectRatio,
+ aspectRatioX,
+ aspectRatioY,
+ 1 / (float) scale,
+ flipHorizontally,
+ flipVertically);
+ return new BitmapSampled(cropBitmap, scale);
+ } catch (OutOfMemoryError e) {
+ scale *= 2;
+ if (scale > 8) {
+ throw e;
+ }
+ }
+ }
+ }
+
+ /**
+ * Crop image bitmap from given bitmap using the given points in the original bitmap and the given
+ * rotation.
+ * if the rotation is not 0,90,180 or 270 degrees then we must first crop a larger area of the
+ * image that contains the requires rectangle, rotate and then crop again a sub rectangle.
+ *
+ * @param scale how much to scale the cropped image part, use 0.5 to lower the image by half (OOM
+ * handling)
+ */
+ private static Bitmap cropBitmapObjectWithScale(
+ Bitmap bitmap,
+ float[] points,
+ int degreesRotated,
+ boolean fixAspectRatio,
+ int aspectRatioX,
+ int aspectRatioY,
+ float scale,
+ boolean flipHorizontally,
+ boolean flipVertically) {
+
+ // get the rectangle in original image that contains the required cropped area (larger for non
+ // rectangular crop)
+ Rect rect =
+ getRectFromPoints(
+ points,
+ bitmap.getWidth(),
+ bitmap.getHeight(),
+ fixAspectRatio,
+ aspectRatioX,
+ aspectRatioY);
+
+ // crop and rotate the cropped image in one operation
+ Matrix matrix = new Matrix();
+ matrix.setRotate(degreesRotated, bitmap.getWidth() / 2, bitmap.getHeight() / 2);
+ matrix.postScale(flipHorizontally ? -scale : scale, flipVertically ? -scale : scale);
+ Bitmap result =
+ Bitmap.createBitmap(bitmap, rect.left, rect.top, rect.width(), rect.height(), matrix, true);
+
+ if (result == bitmap) {
+ // corner case when all bitmap is selected, no worth optimizing for it
+ result = bitmap.copy(bitmap.getConfig(), false);
+ }
+
+ // rotating by 0, 90, 180 or 270 degrees doesn't require extra cropping
+ if (degreesRotated % 90 != 0) {
+
+ // extra crop because non rectangular crop cannot be done directly on the image without
+ // rotating first
+ result =
+ cropForRotatedImage(
+ result, points, rect, degreesRotated, fixAspectRatio, aspectRatioX, aspectRatioY);
+ }
+
+ return result;
+ }
+
+ /**
+ * Crop image bitmap from URI by decoding it with specific width and height to down-sample if
+ * required.
+ * Additionally if OOM is thrown try to increase the sampling (2,4,8).
+ */
+ static BitmapSampled cropBitmap(
+ Context context,
+ Uri loadedImageUri,
+ float[] points,
+ int degreesRotated,
+ int orgWidth,
+ int orgHeight,
+ boolean fixAspectRatio,
+ int aspectRatioX,
+ int aspectRatioY,
+ int reqWidth,
+ int reqHeight,
+ boolean flipHorizontally,
+ boolean flipVertically) {
+ int sampleMulti = 1;
+ while (true) {
+ try {
+ // if successful, just return the resulting bitmap
+ return cropBitmap(
+ context,
+ loadedImageUri,
+ points,
+ degreesRotated,
+ orgWidth,
+ orgHeight,
+ fixAspectRatio,
+ aspectRatioX,
+ aspectRatioY,
+ reqWidth,
+ reqHeight,
+ flipHorizontally,
+ flipVertically,
+ sampleMulti);
+ } catch (OutOfMemoryError e) {
+ // if OOM try to increase the sampling to lower the memory usage
+ sampleMulti *= 2;
+ if (sampleMulti > 16) {
+ throw new RuntimeException(
+ "Failed to handle OOM by sampling ("
+ + sampleMulti
+ + "): "
+ + loadedImageUri
+ + "\r\n"
+ + e.getMessage(),
+ e);
+ }
+ }
+ }
+ }
+
+ /** Get left value of the bounding rectangle of the given points. */
+ static float getRectLeft(float[] points) {
+ return Math.min(Math.min(Math.min(points[0], points[2]), points[4]), points[6]);
+ }
+
+ /** Get top value of the bounding rectangle of the given points. */
+ static float getRectTop(float[] points) {
+ return Math.min(Math.min(Math.min(points[1], points[3]), points[5]), points[7]);
+ }
+
+ /** Get right value of the bounding rectangle of the given points. */
+ static float getRectRight(float[] points) {
+ return Math.max(Math.max(Math.max(points[0], points[2]), points[4]), points[6]);
+ }
+
+ /** Get bottom value of the bounding rectangle of the given points. */
+ static float getRectBottom(float[] points) {
+ return Math.max(Math.max(Math.max(points[1], points[3]), points[5]), points[7]);
+ }
+
+ /** Get width of the bounding rectangle of the given points. */
+ static float getRectWidth(float[] points) {
+ return getRectRight(points) - getRectLeft(points);
+ }
+
+ /** Get height of the bounding rectangle of the given points. */
+ static float getRectHeight(float[] points) {
+ return getRectBottom(points) - getRectTop(points);
+ }
+
+ /** Get horizontal center value of the bounding rectangle of the given points. */
+ static float getRectCenterX(float[] points) {
+ return (getRectRight(points) + getRectLeft(points)) / 2f;
+ }
+
+ /** Get vertical center value of the bounding rectangle of the given points. */
+ static float getRectCenterY(float[] points) {
+ return (getRectBottom(points) + getRectTop(points)) / 2f;
+ }
+
+ /**
+ * Get a rectangle for the given 4 points (x0,y0,x1,y1,x2,y2,x3,y3) by finding the min/max 2
+ * points that contains the given 4 points and is a straight rectangle.
+ */
+ static Rect getRectFromPoints(
+ float[] points,
+ int imageWidth,
+ int imageHeight,
+ boolean fixAspectRatio,
+ int aspectRatioX,
+ int aspectRatioY) {
+ int left = Math.round(Math.max(0, getRectLeft(points)));
+ int top = Math.round(Math.max(0, getRectTop(points)));
+ int right = Math.round(Math.min(imageWidth, getRectRight(points)));
+ int bottom = Math.round(Math.min(imageHeight, getRectBottom(points)));
+
+ Rect rect = new Rect(left, top, right, bottom);
+ if (fixAspectRatio) {
+ fixRectForAspectRatio(rect, aspectRatioX, aspectRatioY);
+ }
+
+ return rect;
+ }
+
+ /**
+ * Fix the given rectangle if it doesn't confirm to aspect ration rule.
+ * Make sure that width and height are equal if 1:1 fixed aspect ratio is requested.
+ */
+ private static void fixRectForAspectRatio(Rect rect, int aspectRatioX, int aspectRatioY) {
+ if (aspectRatioX == aspectRatioY && rect.width() != rect.height()) {
+ if (rect.height() > rect.width()) {
+ rect.bottom -= rect.height() - rect.width();
+ } else {
+ rect.right -= rect.width() - rect.height();
+ }
+ }
+ }
+
+ /**
+ * Write given bitmap to a temp file. If file already exists no-op as we already saved the file in
+ * this session. Uses JPEG 95% compression.
+ *
+ * @param uri the uri to write the bitmap to, if null
+ * @return the uri where the image was saved in, either the given uri or new pointing to temp
+ * file.
+ */
+ static Uri writeTempStateStoreBitmap(Context context, Bitmap bitmap, Uri uri) {
+ try {
+ boolean needSave = true;
+ if (uri == null) {
+ uri =
+ Uri.fromFile(
+ File.createTempFile("aic_state_store_temp", ".jpg", context.getCacheDir()));
+ } else if (new File(uri.getPath()).exists()) {
+ needSave = false;
+ }
+ if (needSave) {
+ writeBitmapToUri(context, bitmap, uri, Bitmap.CompressFormat.JPEG, 95);
+ }
+ return uri;
+ } catch (Exception e) {
+ Log.w("AIC", "Failed to write bitmap to temp file for image-cropper save instance state", e);
+ return null;
+ }
+ }
+
+ /** Write the given bitmap to the given uri using the given compression. */
+ static void writeBitmapToUri(
+ Context context,
+ Bitmap bitmap,
+ Uri uri,
+ Bitmap.CompressFormat compressFormat,
+ int compressQuality)
+ throws FileNotFoundException {
+ OutputStream outputStream = null;
+ try {
+ outputStream = context.getContentResolver().openOutputStream(uri);
+ bitmap.compress(compressFormat, compressQuality, outputStream);
+ } finally {
+ closeSafe(outputStream);
+ }
+ }
+
+ /** Resize the given bitmap to the given width/height by the given option.
*/
+ static Bitmap resizeBitmap(
+ Bitmap bitmap, int reqWidth, int reqHeight, CropImageView.RequestSizeOptions options) {
+ try {
+ if (reqWidth > 0
+ && reqHeight > 0
+ && (options == CropImageView.RequestSizeOptions.RESIZE_FIT
+ || options == CropImageView.RequestSizeOptions.RESIZE_INSIDE
+ || options == CropImageView.RequestSizeOptions.RESIZE_EXACT)) {
+
+ Bitmap resized = null;
+ if (options == CropImageView.RequestSizeOptions.RESIZE_EXACT) {
+ resized = Bitmap.createScaledBitmap(bitmap, reqWidth, reqHeight, false);
+ } else {
+ int width = bitmap.getWidth();
+ int height = bitmap.getHeight();
+ float scale = Math.max(width / (float) reqWidth, height / (float) reqHeight);
+ if (scale > 1 || options == CropImageView.RequestSizeOptions.RESIZE_FIT) {
+ resized =
+ Bitmap.createScaledBitmap(
+ bitmap, (int) (width / scale), (int) (height / scale), false);
+ }
+ }
+ if (resized != null) {
+ if (resized != bitmap) {
+ bitmap.recycle();
+ }
+ return resized;
+ }
+ }
+ } catch (Exception e) {
+ Log.w("AIC", "Failed to resize cropped image, return bitmap before resize", e);
+ }
+ return bitmap;
+ }
+
+ // region: Private methods
+
+ /**
+ * Crop image bitmap from URI by decoding it with specific width and height to down-sample if
+ * required.
+ *
+ * @param orgWidth used to get rectangle from points (handle edge cases to limit rectangle)
+ * @param orgHeight used to get rectangle from points (handle edge cases to limit rectangle)
+ * @param sampleMulti used to increase the sampling of the image to handle memory issues.
+ */
+ private static BitmapSampled cropBitmap(
+ Context context,
+ Uri loadedImageUri,
+ float[] points,
+ int degreesRotated,
+ int orgWidth,
+ int orgHeight,
+ boolean fixAspectRatio,
+ int aspectRatioX,
+ int aspectRatioY,
+ int reqWidth,
+ int reqHeight,
+ boolean flipHorizontally,
+ boolean flipVertically,
+ int sampleMulti) {
+
+ // get the rectangle in original image that contains the required cropped area (larger for non
+ // rectangular crop)
+ Rect rect =
+ getRectFromPoints(points, orgWidth, orgHeight, fixAspectRatio, aspectRatioX, aspectRatioY);
+
+ int width = reqWidth > 0 ? reqWidth : rect.width();
+ int height = reqHeight > 0 ? reqHeight : rect.height();
+
+ Bitmap result = null;
+ int sampleSize = 1;
+ try {
+ // decode only the required image from URI, optionally sub-sampling if reqWidth/reqHeight is
+ // given.
+ BitmapSampled bitmapSampled =
+ decodeSampledBitmapRegion(context, loadedImageUri, rect, width, height, sampleMulti);
+ result = bitmapSampled.bitmap;
+ sampleSize = bitmapSampled.sampleSize;
+ } catch (Exception ignored) {
+ }
+
+ if (result != null) {
+ try {
+ // rotate the decoded region by the required amount
+ result = rotateAndFlipBitmapInt(result, degreesRotated, flipHorizontally, flipVertically);
+
+ // rotating by 0, 90, 180 or 270 degrees doesn't require extra cropping
+ if (degreesRotated % 90 != 0) {
+
+ // extra crop because non rectangular crop cannot be done directly on the image without
+ // rotating first
+ result =
+ cropForRotatedImage(
+ result, points, rect, degreesRotated, fixAspectRatio, aspectRatioX, aspectRatioY);
+ }
+ } catch (OutOfMemoryError e) {
+ if (result != null) {
+ result.recycle();
+ }
+ throw e;
+ }
+ return new BitmapSampled(result, sampleSize);
+ } else {
+ // failed to decode region, may be skia issue, try full decode and then crop
+ return cropBitmap(
+ context,
+ loadedImageUri,
+ points,
+ degreesRotated,
+ fixAspectRatio,
+ aspectRatioX,
+ aspectRatioY,
+ sampleMulti,
+ rect,
+ width,
+ height,
+ flipHorizontally,
+ flipVertically);
+ }
+ }
+
+ /**
+ * Crop bitmap by fully loading the original and then cropping it, fallback in case cropping
+ * region failed.
+ */
+ private static BitmapSampled cropBitmap(
+ Context context,
+ Uri loadedImageUri,
+ float[] points,
+ int degreesRotated,
+ boolean fixAspectRatio,
+ int aspectRatioX,
+ int aspectRatioY,
+ int sampleMulti,
+ Rect rect,
+ int width,
+ int height,
+ boolean flipHorizontally,
+ boolean flipVertically) {
+ Bitmap result = null;
+ int sampleSize;
+ try {
+ BitmapFactory.Options options = new BitmapFactory.Options();
+ options.inSampleSize =
+ sampleSize =
+ sampleMulti
+ * calculateInSampleSizeByReqestedSize(rect.width(), rect.height(), width, height);
+
+ Bitmap fullBitmap = decodeImage(context.getContentResolver(), loadedImageUri, options);
+ if (fullBitmap != null) {
+ try {
+ // adjust crop points by the sampling because the image is smaller
+ float[] points2 = new float[points.length];
+ System.arraycopy(points, 0, points2, 0, points.length);
+ for (int i = 0; i < points2.length; i++) {
+ points2[i] = points2[i] / options.inSampleSize;
+ }
+
+ result =
+ cropBitmapObjectWithScale(
+ fullBitmap,
+ points2,
+ degreesRotated,
+ fixAspectRatio,
+ aspectRatioX,
+ aspectRatioY,
+ 1,
+ flipHorizontally,
+ flipVertically);
+ } finally {
+ if (result != fullBitmap) {
+ fullBitmap.recycle();
+ }
+ }
+ }
+ } catch (OutOfMemoryError e) {
+ if (result != null) {
+ result.recycle();
+ }
+ throw e;
+ } catch (Exception e) {
+ throw new RuntimeException(
+ "Failed to load sampled bitmap: " + loadedImageUri + "\r\n" + e.getMessage(), e);
+ }
+ return new BitmapSampled(result, sampleSize);
+ }
+
+ /** Decode image from uri using "inJustDecodeBounds" to get the image dimensions. */
+ private static BitmapFactory.Options decodeImageForOption(ContentResolver resolver, Uri uri)
+ throws FileNotFoundException {
+ InputStream stream = null;
+ try {
+ stream = resolver.openInputStream(uri);
+ BitmapFactory.Options options = new BitmapFactory.Options();
+ options.inJustDecodeBounds = true;
+ BitmapFactory.decodeStream(stream, EMPTY_RECT, options);
+ options.inJustDecodeBounds = false;
+ return options;
+ } finally {
+ closeSafe(stream);
+ }
+ }
+
+ /**
+ * Decode image from uri using given "inSampleSize", but if failed due to out-of-memory then raise
+ * the inSampleSize until success.
+ */
+ private static Bitmap decodeImage(
+ ContentResolver resolver, Uri uri, BitmapFactory.Options options)
+ throws FileNotFoundException {
+ do {
+ InputStream stream = null;
+ try {
+ stream = resolver.openInputStream(uri);
+ return BitmapFactory.decodeStream(stream, EMPTY_RECT, options);
+ } catch (OutOfMemoryError e) {
+ options.inSampleSize *= 2;
+ } finally {
+ closeSafe(stream);
+ }
+ } while (options.inSampleSize <= 512);
+ throw new RuntimeException("Failed to decode image: " + uri);
+ }
+
+ /**
+ * Decode specific rectangle bitmap from stream using sampling to get bitmap with the requested
+ * limit.
+ *
+ * @param sampleMulti used to increase the sampling of the image to handle memory issues.
+ */
+ private static BitmapSampled decodeSampledBitmapRegion(
+ Context context, Uri uri, Rect rect, int reqWidth, int reqHeight, int sampleMulti) {
+ InputStream stream = null;
+ BitmapRegionDecoder decoder = null;
+ try {
+ BitmapFactory.Options options = new BitmapFactory.Options();
+ options.inSampleSize =
+ sampleMulti
+ * calculateInSampleSizeByReqestedSize(
+ rect.width(), rect.height(), reqWidth, reqHeight);
+
+ stream = context.getContentResolver().openInputStream(uri);
+ decoder = BitmapRegionDecoder.newInstance(stream, false);
+ do {
+ try {
+ return new BitmapSampled(decoder.decodeRegion(rect, options), options.inSampleSize);
+ } catch (OutOfMemoryError e) {
+ options.inSampleSize *= 2;
+ }
+ } while (options.inSampleSize <= 512);
+ } catch (Exception e) {
+ throw new RuntimeException(
+ "Failed to load sampled bitmap: " + uri + "\r\n" + e.getMessage(), e);
+ } finally {
+ closeSafe(stream);
+ if (decoder != null) {
+ decoder.recycle();
+ }
+ }
+ return new BitmapSampled(null, 1);
+ }
+
+ /**
+ * Special crop of bitmap rotated by not stright angle, in this case the original crop bitmap
+ * contains parts beyond the required crop area, this method crops the already cropped and rotated
+ * bitmap to the final rectangle.
+ * Note: rotating by 0, 90, 180 or 270 degrees doesn't require extra cropping.
+ */
+ private static Bitmap cropForRotatedImage(
+ Bitmap bitmap,
+ float[] points,
+ Rect rect,
+ int degreesRotated,
+ boolean fixAspectRatio,
+ int aspectRatioX,
+ int aspectRatioY) {
+ if (degreesRotated % 90 != 0) {
+
+ int adjLeft = 0, adjTop = 0, width = 0, height = 0;
+ double rads = Math.toRadians(degreesRotated);
+ int compareTo =
+ degreesRotated < 90 || (degreesRotated > 180 && degreesRotated < 270)
+ ? rect.left
+ : rect.right;
+ for (int i = 0; i < points.length; i += 2) {
+ if (points[i] >= compareTo - 1 && points[i] <= compareTo + 1) {
+ adjLeft = (int) Math.abs(Math.sin(rads) * (rect.bottom - points[i + 1]));
+ adjTop = (int) Math.abs(Math.cos(rads) * (points[i + 1] - rect.top));
+ width = (int) Math.abs((points[i + 1] - rect.top) / Math.sin(rads));
+ height = (int) Math.abs((rect.bottom - points[i + 1]) / Math.cos(rads));
+ break;
+ }
+ }
+
+ rect.set(adjLeft, adjTop, adjLeft + width, adjTop + height);
+ if (fixAspectRatio) {
+ fixRectForAspectRatio(rect, aspectRatioX, aspectRatioY);
+ }
+
+ Bitmap bitmapTmp = bitmap;
+ bitmap = Bitmap.createBitmap(bitmap, rect.left, rect.top, rect.width(), rect.height());
+ if (bitmapTmp != bitmap) {
+ bitmapTmp.recycle();
+ }
+ }
+ return bitmap;
+ }
+
+ /**
+ * Calculate the largest inSampleSize value that is a power of 2 and keeps both height and width
+ * larger than the requested height and width.
+ */
+ private static int calculateInSampleSizeByReqestedSize(
+ int width, int height, int reqWidth, int reqHeight) {
+ int inSampleSize = 1;
+ if (height > reqHeight || width > reqWidth) {
+ while ((height / 2 / inSampleSize) > reqHeight && (width / 2 / inSampleSize) > reqWidth) {
+ inSampleSize *= 2;
+ }
+ }
+ return inSampleSize;
+ }
+
+ /**
+ * Calculate the largest inSampleSize value that is a power of 2 and keeps both height and width
+ * smaller than max texture size allowed for the device.
+ */
+ private static int calculateInSampleSizeByMaxTextureSize(int width, int height) {
+ int inSampleSize = 1;
+ if (mMaxTextureSize == 0) {
+ mMaxTextureSize = getMaxTextureSize();
+ }
+ if (mMaxTextureSize > 0) {
+ while ((height / inSampleSize) > mMaxTextureSize
+ || (width / inSampleSize) > mMaxTextureSize) {
+ inSampleSize *= 2;
+ }
+ }
+ return inSampleSize;
+ }
+
+ /**
+ * Rotate the given bitmap by the given degrees.
+ * New bitmap is created and the old one is recycled.
+ */
+ private static Bitmap rotateAndFlipBitmapInt(
+ Bitmap bitmap, int degrees, boolean flipHorizontally, boolean flipVertically) {
+ if (degrees > 0 || flipHorizontally || flipVertically) {
+ Matrix matrix = new Matrix();
+ matrix.setRotate(degrees);
+ matrix.postScale(flipHorizontally ? -1 : 1, flipVertically ? -1 : 1);
+ Bitmap newBitmap =
+ Bitmap.createBitmap(bitmap, 0, 0, bitmap.getWidth(), bitmap.getHeight(), matrix, false);
+ if (newBitmap != bitmap) {
+ bitmap.recycle();
+ }
+ return newBitmap;
+ } else {
+ return bitmap;
+ }
+ }
+
+ /**
+ * Get the max size of bitmap allowed to be rendered on the device.
+ * http://stackoverflow.com/questions/7428996/hw-accelerated-activity-how-to-get-opengl-texture-size-limit.
+ */
+ private static int getMaxTextureSize() {
+ // Safe minimum default size
+ final int IMAGE_MAX_BITMAP_DIMENSION = 2048;
+
+ try {
+ // Get EGL Display
+ EGL10 egl = (EGL10) EGLContext.getEGL();
+ EGLDisplay display = egl.eglGetDisplay(EGL10.EGL_DEFAULT_DISPLAY);
+
+ // Initialise
+ int[] version = new int[2];
+ egl.eglInitialize(display, version);
+
+ // Query total number of configurations
+ int[] totalConfigurations = new int[1];
+ egl.eglGetConfigs(display, null, 0, totalConfigurations);
+
+ // Query actual list configurations
+ EGLConfig[] configurationsList = new EGLConfig[totalConfigurations[0]];
+ egl.eglGetConfigs(display, configurationsList, totalConfigurations[0], totalConfigurations);
+
+ int[] textureSize = new int[1];
+ int maximumTextureSize = 0;
+
+ // Iterate through all the configurations to located the maximum texture size
+ for (int i = 0; i < totalConfigurations[0]; i++) {
+ // Only need to check for width since opengl textures are always squared
+ egl.eglGetConfigAttrib(
+ display, configurationsList[i], EGL10.EGL_MAX_PBUFFER_WIDTH, textureSize);
+
+ // Keep track of the maximum texture size
+ if (maximumTextureSize < textureSize[0]) {
+ maximumTextureSize = textureSize[0];
+ }
+ }
+
+ // Release
+ egl.eglTerminate(display);
+
+ // Return largest texture size found, or default
+ return Math.max(maximumTextureSize, IMAGE_MAX_BITMAP_DIMENSION);
+ } catch (Exception e) {
+ return IMAGE_MAX_BITMAP_DIMENSION;
+ }
+ }
+
+ /**
+ * Close the given closeable object (Stream) in a safe way: check if it is null and catch-log
+ * exception thrown.
+ *
+ * @param closeable the closable object to close
+ */
+ private static void closeSafe(Closeable closeable) {
+ if (closeable != null) {
+ try {
+ closeable.close();
+ } catch (IOException ignored) {
+ }
+ }
+ }
+ // endregion
+
+ // region: Inner class: BitmapSampled
+
+ /** Holds bitmap instance and the sample size that the bitmap was loaded/cropped with. */
+ static final class BitmapSampled {
+
+ /** The bitmap instance */
+ public final Bitmap bitmap;
+
+ /** The sample size used to lower the size of the bitmap (1,2,4,8,...) */
+ final int sampleSize;
+
+ BitmapSampled(Bitmap bitmap, int sampleSize) {
+ this.bitmap = bitmap;
+ this.sampleSize = sampleSize;
+ }
+ }
+ // endregion
+
+ // region: Inner class: RotateBitmapResult
+
+ /** The result of {@link #rotateBitmapByExif(android.graphics.Bitmap, ExifInterface)}. */
+ static final class RotateBitmapResult {
+
+ /** The loaded bitmap */
+ public final Bitmap bitmap;
+
+ /** The degrees the image was rotated */
+ final int degrees;
+
+ RotateBitmapResult(Bitmap bitmap, int degrees) {
+ this.bitmap = bitmap;
+ this.degrees = degrees;
+ }
+ }
+ // endregion
+}
diff --git a/cropper/src/main/java/com/theartofdev/edmodo/cropper/CropImage.java b/cropper/src/main/java/com/theartofdev/edmodo/cropper/CropImage.java
new file mode 100644
index 0000000..ba8b807
--- /dev/null
+++ b/cropper/src/main/java/com/theartofdev/edmodo/cropper/CropImage.java
@@ -0,0 +1,1018 @@
+// "Therefore those skilled at the unorthodox
+// are infinite as heaven and earth,
+// inexhaustible as the great rivers.
+// When they come to an end,
+// they begin again,
+// like the days and months;
+// they die and are reborn,
+// like the four seasons."
+//
+// - Sun Tsu,
+// "The Art of War"
+
+package com.theartofdev.edmodo.cropper;
+
+import android.Manifest;
+import android.app.Activity;
+import android.content.ComponentName;
+import android.content.ContentResolver;
+import android.content.Context;
+import android.content.Intent;
+import android.content.pm.PackageInfo;
+import android.content.pm.PackageManager;
+import android.content.pm.ResolveInfo;
+import android.graphics.Bitmap;
+import android.graphics.Canvas;
+import android.graphics.Paint;
+import android.graphics.PorterDuff;
+import android.graphics.PorterDuffXfermode;
+import android.graphics.Rect;
+import android.graphics.RectF;
+import android.net.Uri;
+import android.os.Build;
+import android.os.Bundle;
+import android.os.Parcel;
+import android.os.Parcelable;
+import android.provider.MediaStore;
+
+import java.io.File;
+import java.io.InputStream;
+import java.util.ArrayList;
+import java.util.List;
+
+import androidx.annotation.DrawableRes;
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.annotation.RequiresApi;
+import androidx.fragment.app.Fragment;
+
+/**
+ * Helper to simplify crop image work like starting pick-image acitvity and handling camera/gallery
+ * intents.
+ * The goal of the helper is to simplify the starting and most-common usage of image cropping and
+ * not all porpose all possible scenario one-to-rule-them-all code base. So feel free to use it as
+ * is and as a wiki to make your own.
+ * Added value you get out-of-the-box is some edge case handling that you may miss otherwise, like
+ * the stupid-ass Android camera result URI that may differ from version to version and from device
+ * to device.
+ */
+@SuppressWarnings("WeakerAccess, unused")
+public final class CropImage {
+
+ // region: Fields and Consts
+
+ /** The key used to pass crop image source URI to {@link CropImageActivity}. */
+ public static final String CROP_IMAGE_EXTRA_SOURCE = "CROP_IMAGE_EXTRA_SOURCE";
+
+ /** The key used to pass crop image options to {@link CropImageActivity}. */
+ public static final String CROP_IMAGE_EXTRA_OPTIONS = "CROP_IMAGE_EXTRA_OPTIONS";
+
+ /** The key used to pass crop image bundle data to {@link CropImageActivity}. */
+ public static final String CROP_IMAGE_EXTRA_BUNDLE = "CROP_IMAGE_EXTRA_BUNDLE";
+
+ /** The key used to pass crop image result data back from {@link CropImageActivity}. */
+ public static final String CROP_IMAGE_EXTRA_RESULT = "CROP_IMAGE_EXTRA_RESULT";
+
+ /**
+ * The request code used to start pick image activity to be used on result to identify the this
+ * specific request.
+ */
+ public static final int PICK_IMAGE_CHOOSER_REQUEST_CODE = 200;
+
+ /** The request code used to request permission to pick image from external storage. */
+ public static final int PICK_IMAGE_PERMISSIONS_REQUEST_CODE = 201;
+
+ /** The request code used to request permission to capture image from camera. */
+ public static final int CAMERA_CAPTURE_PERMISSIONS_REQUEST_CODE = 2011;
+
+ /**
+ * The request code used to start {@link CropImageActivity} to be used on result to identify the
+ * this specific request.
+ */
+ public static final int CROP_IMAGE_ACTIVITY_REQUEST_CODE = 203;
+
+ /** The result code used to return error from {@link CropImageActivity}. */
+ public static final int CROP_IMAGE_ACTIVITY_RESULT_ERROR_CODE = 204;
+ // endregion
+
+ private CropImage() {}
+
+ /**
+ * Create a new bitmap that has all pixels beyond the oval shape transparent. Old bitmap is
+ * recycled.
+ */
+ public static Bitmap toOvalBitmap(@NonNull Bitmap bitmap) {
+ int width = bitmap.getWidth();
+ int height = bitmap.getHeight();
+ Bitmap output = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888);
+
+ Canvas canvas = new Canvas(output);
+
+ int color = 0xff424242;
+ Paint paint = new Paint();
+
+ paint.setAntiAlias(true);
+ canvas.drawARGB(0, 0, 0, 0);
+ paint.setColor(color);
+
+ RectF rect = new RectF(0, 0, width, height);
+ canvas.drawOval(rect, paint);
+ paint.setXfermode(new PorterDuffXfermode(PorterDuff.Mode.SRC_IN));
+ canvas.drawBitmap(bitmap, 0, 0, paint);
+
+ bitmap.recycle();
+
+ return output;
+ }
+
+ /**
+ * Start an activity to get image for cropping using chooser intent that will have all the
+ * available applications for the device like camera (MyCamera), galery (Photos), store apps
+ * (Dropbox), etc.
+ * Use "pick_image_intent_chooser_title" string resource to override pick chooser title.
+ *
+ * @param activity the activity to be used to start activity from
+ */
+ public static void startPickImageActivity(@NonNull Activity activity) {
+ activity.startActivityForResult(
+ getPickImageChooserIntent(activity), PICK_IMAGE_CHOOSER_REQUEST_CODE);
+ }
+
+ /**
+ * Same as {@link #startPickImageActivity(Activity) startPickImageActivity} method but instead of
+ * being called and returning to an Activity, this method can be called and return to a Fragment.
+ *
+ * @param context The Fragments context. Use getContext()
+ * @param fragment The calling Fragment to start and return the image to
+ */
+ public static void startPickImageActivity(@NonNull Context context, @NonNull Fragment fragment) {
+ fragment.startActivityForResult(
+ getPickImageChooserIntent(context), PICK_IMAGE_CHOOSER_REQUEST_CODE);
+ }
+
+ /**
+ * Create a chooser intent to select the source to get image from.
+ * The source can be camera's (ACTION_IMAGE_CAPTURE) or gallery's (ACTION_GET_CONTENT).
+ * All possible sources are added to the intent chooser.
+ * Use "pick_image_intent_chooser_title" string resource to override chooser title.
+ *
+ * @param context used to access Android APIs, like content resolve, it is your
+ * activity/fragment/widget.
+ */
+ public static Intent getPickImageChooserIntent(@NonNull Context context) {
+ return getPickImageChooserIntent(
+ context, context.getString(R.string.pick_image_intent_chooser_title), false, true);
+ }
+
+ /**
+ * Create a chooser intent to select the source to get image from.
+ * The source can be camera's (ACTION_IMAGE_CAPTURE) or gallery's (ACTION_GET_CONTENT).
+ * All possible sources are added to the intent chooser.
+ *
+ * @param context used to access Android APIs, like content resolve, it is your
+ * activity/fragment/widget.
+ * @param title the title to use for the chooser UI
+ * @param includeDocuments if to include KitKat documents activity containing all sources
+ * @param includeCamera if to include camera intents
+ */
+ public static Intent getPickImageChooserIntent(
+ @NonNull Context context,
+ CharSequence title,
+ boolean includeDocuments,
+ boolean includeCamera) {
+
+ List allIntents = new ArrayList<>();
+ PackageManager packageManager = context.getPackageManager();
+
+ // collect all camera intents if Camera permission is available
+ if (!isExplicitCameraPermissionRequired(context) && includeCamera) {
+ allIntents.addAll(getCameraIntents(context, packageManager));
+ }
+
+ List galleryIntents =
+ getGalleryIntents(packageManager, Intent.ACTION_GET_CONTENT, includeDocuments);
+ if (galleryIntents.size() == 0) {
+ // if no intents found for get-content try pick intent action (Huawei P9).
+ galleryIntents = getGalleryIntents(packageManager, Intent.ACTION_PICK, includeDocuments);
+ }
+ allIntents.addAll(galleryIntents);
+
+ Intent target;
+ if (allIntents.isEmpty()) {
+ target = new Intent();
+ } else {
+ target = allIntents.get(allIntents.size() - 1);
+ allIntents.remove(allIntents.size() - 1);
+ }
+
+ // Create a chooser from the main intent
+ Intent chooserIntent = Intent.createChooser(target, title);
+
+ // Add all other intents
+ chooserIntent.putExtra(
+ Intent.EXTRA_INITIAL_INTENTS, allIntents.toArray(new Parcelable[allIntents.size()]));
+
+ return chooserIntent;
+ }
+
+ /**
+ * Get the main Camera intent for capturing image using device camera app. If the outputFileUri is
+ * null, a default Uri will be created with {@link #getCaptureImageOutputUri(Context)}, so then
+ * you will be able to get the pictureUri using {@link #getPickImageResultUri(Context, Intent)}.
+ * Otherwise, it is just you use the Uri passed to this method.
+ *
+ * @param context used to access Android APIs, like content resolve, it is your
+ * activity/fragment/widget.
+ * @param outputFileUri the Uri where the picture will be placed.
+ */
+ public static Intent getCameraIntent(@NonNull Context context, Uri outputFileUri) {
+ Intent intent = new Intent(MediaStore.ACTION_IMAGE_CAPTURE);
+ if (outputFileUri == null) {
+ outputFileUri = getCaptureImageOutputUri(context);
+ }
+ intent.putExtra(MediaStore.EXTRA_OUTPUT, outputFileUri);
+ return intent;
+ }
+
+ /** Get all Camera intents for capturing image using device camera apps. */
+ public static List getCameraIntents(
+ @NonNull Context context, @NonNull PackageManager packageManager) {
+
+ List allIntents = new ArrayList<>();
+
+ // Determine Uri of camera image to save.
+ Uri outputFileUri = getCaptureImageOutputUri(context);
+
+ Intent captureIntent = new Intent(MediaStore.ACTION_IMAGE_CAPTURE);
+ List listCam = packageManager.queryIntentActivities(captureIntent, 0);
+ for (ResolveInfo res : listCam) {
+ Intent intent = new Intent(captureIntent);
+ intent.setComponent(new ComponentName(res.activityInfo.packageName, res.activityInfo.name));
+ intent.setPackage(res.activityInfo.packageName);
+ if (outputFileUri != null) {
+ intent.putExtra(MediaStore.EXTRA_OUTPUT, outputFileUri);
+ }
+ allIntents.add(intent);
+ }
+
+ return allIntents;
+ }
+
+ /**
+ * Get all Gallery intents for getting image from one of the apps of the device that handle
+ * images.
+ */
+ public static List getGalleryIntents(
+ @NonNull PackageManager packageManager, String action, boolean includeDocuments) {
+ List intents = new ArrayList<>();
+ Intent galleryIntent =
+ action == Intent.ACTION_GET_CONTENT
+ ? new Intent(action)
+ : new Intent(action, MediaStore.Images.Media.EXTERNAL_CONTENT_URI);
+ galleryIntent.setType("image/*");
+ List listGallery = packageManager.queryIntentActivities(galleryIntent, 0);
+ for (ResolveInfo res : listGallery) {
+ Intent intent = new Intent(galleryIntent);
+ intent.setComponent(new ComponentName(res.activityInfo.packageName, res.activityInfo.name));
+ intent.setPackage(res.activityInfo.packageName);
+ intents.add(intent);
+ }
+
+ // remove documents intent
+ if (!includeDocuments) {
+ for (Intent intent : intents) {
+ if (intent
+ .getComponent()
+ .getClassName()
+ .equals("com.android.documentsui.DocumentsActivity")) {
+ intents.remove(intent);
+ break;
+ }
+ }
+ }
+ return intents;
+ }
+
+ /**
+ * Check if explicetly requesting camera permission is required.
+ * It is required in Android Marshmellow and above if "CAMERA" permission is requested in the
+ * manifest.
+ * See StackOverflow
+ * question.
+ */
+ public static boolean isExplicitCameraPermissionRequired(@NonNull Context context) {
+ return Build.VERSION.SDK_INT >= Build.VERSION_CODES.M
+ && hasPermissionInManifest(context, "android.permission.CAMERA")
+ && context.checkSelfPermission(Manifest.permission.CAMERA)
+ != PackageManager.PERMISSION_GRANTED;
+ }
+
+ /**
+ * Check if the app requests a specific permission in the manifest.
+ *
+ * @param permissionName the permission to check
+ * @return true - the permission in requested in manifest, false - not.
+ */
+ public static boolean hasPermissionInManifest(
+ @NonNull Context context, @NonNull String permissionName) {
+ String packageName = context.getPackageName();
+ try {
+ PackageInfo packageInfo =
+ context.getPackageManager().getPackageInfo(packageName, PackageManager.GET_PERMISSIONS);
+ final String[] declaredPermisisons = packageInfo.requestedPermissions;
+ if (declaredPermisisons != null && declaredPermisisons.length > 0) {
+ for (String p : declaredPermisisons) {
+ if (p.equalsIgnoreCase(permissionName)) {
+ return true;
+ }
+ }
+ }
+ } catch (PackageManager.NameNotFoundException e) {
+ }
+ return false;
+ }
+
+ /**
+ * Get URI to image received from capture by camera.
+ *
+ * @param context used to access Android APIs, like content resolve, it is your
+ * activity/fragment/widget.
+ */
+ public static Uri getCaptureImageOutputUri(@NonNull Context context) {
+ Uri outputFileUri = null;
+ File getImage = context.getExternalCacheDir();
+ if (getImage != null) {
+ outputFileUri = Uri.fromFile(new File(getImage.getPath(), "pickImageResult.jpeg"));
+ }
+ return outputFileUri;
+ }
+
+ /**
+ * Get the URI of the selected image from {@link #getPickImageChooserIntent(Context)}.
+ * Will return the correct URI for camera and gallery image.
+ *
+ * @param context used to access Android APIs, like content resolve, it is your
+ * activity/fragment/widget.
+ * @param data the returned data of the activity result
+ */
+ public static Uri getPickImageResultUri(@NonNull Context context, @Nullable Intent data) {
+ boolean isCamera = true;
+ if (data != null && data.getData() != null) {
+ String action = data.getAction();
+ isCamera = action != null && action.equals(MediaStore.ACTION_IMAGE_CAPTURE);
+ }
+ return isCamera || data.getData() == null ? getCaptureImageOutputUri(context) : data.getData();
+ }
+
+ /**
+ * Check if the given picked image URI requires READ_EXTERNAL_STORAGE permissions.
+ * Only relevant for API version 23 and above and not required for all URI's depends on the
+ * implementation of the app that was used for picking the image. So we just test if we can open
+ * the stream or do we get an exception when we try, Android is awesome.
+ *
+ * @param context used to access Android APIs, like content resolve, it is your
+ * activity/fragment/widget.
+ * @param uri the result URI of image pick.
+ * @return true - required permission are not granted, false - either no need for permissions or
+ * they are granted
+ */
+ public static boolean isReadExternalStoragePermissionsRequired(
+ @NonNull Context context, @NonNull Uri uri) {
+ return Build.VERSION.SDK_INT >= Build.VERSION_CODES.M
+ && context.checkSelfPermission(Manifest.permission.READ_EXTERNAL_STORAGE)
+ != PackageManager.PERMISSION_GRANTED
+ && isUriRequiresPermissions(context, uri);
+ }
+
+ /**
+ * Test if we can open the given Android URI to test if permission required error is thrown.
+ * Only relevant for API version 23 and above.
+ *
+ * @param context used to access Android APIs, like content resolve, it is your
+ * activity/fragment/widget.
+ * @param uri the result URI of image pick.
+ */
+ public static boolean isUriRequiresPermissions(@NonNull Context context, @NonNull Uri uri) {
+ try {
+ ContentResolver resolver = context.getContentResolver();
+ InputStream stream = resolver.openInputStream(uri);
+ if (stream != null) {
+ stream.close();
+ }
+ return false;
+ } catch (Exception e) {
+ return true;
+ }
+ }
+
+ /**
+ * Create {@link ActivityBuilder} instance to open image picker for cropping and then start {@link
+ * CropImageActivity} to crop the selected image.
+ * Result will be received in {@link Activity#onActivityResult(int, int, Intent)} and can be
+ * retrieved using {@link #getActivityResult(Intent)}.
+ *
+ * @return builder for Crop Image Activity
+ */
+ public static ActivityBuilder activity() {
+ return new ActivityBuilder(null);
+ }
+
+ /**
+ * Create {@link ActivityBuilder} instance to start {@link CropImageActivity} to crop the given
+ * image.
+ * Result will be received in {@link Activity#onActivityResult(int, int, Intent)} and can be
+ * retrieved using {@link #getActivityResult(Intent)}.
+ *
+ * @param uri the image Android uri source to crop or null to start a picker
+ * @return builder for Crop Image Activity
+ */
+ public static ActivityBuilder activity(@Nullable Uri uri) {
+ return new ActivityBuilder(uri);
+ }
+
+ /**
+ * Get {@link CropImageActivity} result data object for crop image activity started using {@link
+ * #activity(Uri)}.
+ *
+ * @param data result data intent as received in {@link Activity#onActivityResult(int, int,
+ * Intent)}.
+ * @return Crop Image Activity Result object or null if none exists
+ */
+ public static ActivityResult getActivityResult(@Nullable Intent data) {
+ return data != null ? (ActivityResult) data.getParcelableExtra(CROP_IMAGE_EXTRA_RESULT) : null;
+ }
+
+ // region: Inner class: ActivityBuilder
+
+ /** Builder used for creating Image Crop Activity by user request. */
+ public static final class ActivityBuilder {
+
+ /** The image to crop source Android uri. */
+ @Nullable private final Uri mSource;
+
+ /** Options for image crop UX */
+ private final CropImageOptions mOptions;
+
+ private ActivityBuilder(@Nullable Uri source) {
+ mSource = source;
+ mOptions = new CropImageOptions();
+ }
+
+ /** Get {@link CropImageActivity} intent to start the activity. */
+ public Intent getIntent(@NonNull Context context) {
+ return getIntent(context, CropImageActivity.class);
+ }
+
+ /** Get {@link CropImageActivity} intent to start the activity. */
+ public Intent getIntent(@NonNull Context context, @Nullable Class> cls) {
+ mOptions.validate();
+
+ Intent intent = new Intent();
+ intent.setClass(context, cls);
+ Bundle bundle = new Bundle();
+ bundle.putParcelable(CROP_IMAGE_EXTRA_SOURCE, mSource);
+ bundle.putParcelable(CROP_IMAGE_EXTRA_OPTIONS, mOptions);
+ intent.putExtra(CropImage.CROP_IMAGE_EXTRA_BUNDLE, bundle);
+ return intent;
+ }
+
+ /**
+ * Start {@link CropImageActivity}.
+ *
+ * @param activity activity to receive result
+ */
+ public void start(@NonNull Activity activity) {
+ mOptions.validate();
+ activity.startActivityForResult(getIntent(activity), CROP_IMAGE_ACTIVITY_REQUEST_CODE);
+ }
+
+ /**
+ * Start {@link CropImageActivity}.
+ *
+ * @param activity activity to receive result
+ */
+ public void start(@NonNull Activity activity, @Nullable Class> cls) {
+ mOptions.validate();
+ activity.startActivityForResult(getIntent(activity, cls), CROP_IMAGE_ACTIVITY_REQUEST_CODE);
+ }
+
+ /**
+ * Start {@link CropImageActivity}.
+ *
+ * @param fragment fragment to receive result
+ */
+ public void start(@NonNull Context context, @NonNull Fragment fragment) {
+ fragment.startActivityForResult(getIntent(context), CROP_IMAGE_ACTIVITY_REQUEST_CODE);
+ }
+
+ /**
+ * Start {@link CropImageActivity}.
+ *
+ * @param fragment fragment to receive result
+ */
+ @RequiresApi(api = Build.VERSION_CODES.HONEYCOMB)
+ public void start(@NonNull Context context, @NonNull android.app.Fragment fragment) {
+ fragment.startActivityForResult(getIntent(context), CROP_IMAGE_ACTIVITY_REQUEST_CODE);
+ }
+
+ /**
+ * Start {@link CropImageActivity}.
+ *
+ * @param fragment fragment to receive result
+ */
+ public void start(
+ @NonNull Context context, @NonNull Fragment fragment, @Nullable Class> cls) {
+ fragment.startActivityForResult(getIntent(context, cls), CROP_IMAGE_ACTIVITY_REQUEST_CODE);
+ }
+
+ /**
+ * Start {@link CropImageActivity}.
+ *
+ * @param fragment fragment to receive result
+ */
+ @RequiresApi(api = Build.VERSION_CODES.HONEYCOMB)
+ public void start(
+ @NonNull Context context, @NonNull android.app.Fragment fragment, @Nullable Class> cls) {
+ fragment.startActivityForResult(getIntent(context, cls), CROP_IMAGE_ACTIVITY_REQUEST_CODE);
+ }
+
+ /**
+ * The shape of the cropping window.
+ * To set square/circle crop shape set aspect ratio to 1:1.
+ * Default: RECTANGLE
+ */
+ public ActivityBuilder setCropShape(@NonNull CropImageView.CropShape cropShape) {
+ mOptions.cropShape = cropShape;
+ return this;
+ }
+
+ /**
+ * An edge of the crop window will snap to the corresponding edge of a specified bounding box
+ * when the crop window edge is less than or equal to this distance (in pixels) away from the
+ * bounding box edge (in pixels).
+ * Default: 3dp
+ */
+ public ActivityBuilder setSnapRadius(float snapRadius) {
+ mOptions.snapRadius = snapRadius;
+ return this;
+ }
+
+ /**
+ * The radius of the touchable area around the handle (in pixels).
+ * We are basing this value off of the recommended 48dp Rhythm.
+ * See: http://developer.android.com/design/style/metrics-grids.html#48dp-rhythm
+ * Default: 48dp
+ */
+ public ActivityBuilder setTouchRadius(float touchRadius) {
+ mOptions.touchRadius = touchRadius;
+ return this;
+ }
+
+ /**
+ * whether the guidelines should be on, off, or only showing when resizing.
+ * Default: ON_TOUCH
+ */
+ public ActivityBuilder setGuidelines(@NonNull CropImageView.Guidelines guidelines) {
+ mOptions.guidelines = guidelines;
+ return this;
+ }
+
+ /**
+ * The initial scale type of the image in the crop image view
+ * Default: FIT_CENTER
+ */
+ public ActivityBuilder setScaleType(@NonNull CropImageView.ScaleType scaleType) {
+ mOptions.scaleType = scaleType;
+ return this;
+ }
+
+ /**
+ * if to show crop overlay UI what contains the crop window UI surrounded by background over the
+ * cropping image.
+ * default: true, may disable for animation or frame transition.
+ */
+ public ActivityBuilder setShowCropOverlay(boolean showCropOverlay) {
+ mOptions.showCropOverlay = showCropOverlay;
+ return this;
+ }
+
+ /**
+ * if auto-zoom functionality is enabled.
+ * default: true.
+ */
+ public ActivityBuilder setAutoZoomEnabled(boolean autoZoomEnabled) {
+ mOptions.autoZoomEnabled = autoZoomEnabled;
+ return this;
+ }
+
+ /**
+ * if multi touch functionality is enabled.
+ * default: true.
+ */
+ public ActivityBuilder setMultiTouchEnabled(boolean multiTouchEnabled) {
+ mOptions.multiTouchEnabled = multiTouchEnabled;
+ return this;
+ }
+
+ /**
+ * The max zoom allowed during cropping.
+ * Default: 4
+ */
+ public ActivityBuilder setMaxZoom(int maxZoom) {
+ mOptions.maxZoom = maxZoom;
+ return this;
+ }
+
+ /**
+ * The initial crop window padding from image borders in percentage of the cropping image
+ * dimensions.
+ * Default: 0.1
+ */
+ public ActivityBuilder setInitialCropWindowPaddingRatio(float initialCropWindowPaddingRatio) {
+ mOptions.initialCropWindowPaddingRatio = initialCropWindowPaddingRatio;
+ return this;
+ }
+
+ /**
+ * whether the width to height aspect ratio should be maintained or free to change.
+ * Default: false
+ */
+ public ActivityBuilder setFixAspectRatio(boolean fixAspectRatio) {
+ mOptions.fixAspectRatio = fixAspectRatio;
+ return this;
+ }
+
+ /**
+ * the X,Y value of the aspect ratio.
+ * Also sets fixes aspect ratio to TRUE.
+ * Default: 1/1
+ *
+ * @param aspectRatioX the width
+ * @param aspectRatioY the height
+ */
+ public ActivityBuilder setAspectRatio(int aspectRatioX, int aspectRatioY) {
+ mOptions.aspectRatioX = aspectRatioX;
+ mOptions.aspectRatioY = aspectRatioY;
+ mOptions.fixAspectRatio = true;
+ return this;
+ }
+
+ /**
+ * the thickness of the guidelines lines (in pixels).
+ * Default: 3dp
+ */
+ public ActivityBuilder setBorderLineThickness(float borderLineThickness) {
+ mOptions.borderLineThickness = borderLineThickness;
+ return this;
+ }
+
+ /**
+ * the color of the guidelines lines.
+ * Default: Color.argb(170, 255, 255, 255)
+ */
+ public ActivityBuilder setBorderLineColor(int borderLineColor) {
+ mOptions.borderLineColor = borderLineColor;
+ return this;
+ }
+
+ /**
+ * thickness of the corner line (in pixels).
+ * Default: 2dp
+ */
+ public ActivityBuilder setBorderCornerThickness(float borderCornerThickness) {
+ mOptions.borderCornerThickness = borderCornerThickness;
+ return this;
+ }
+
+ /**
+ * the offset of corner line from crop window border (in pixels).
+ * Default: 5dp
+ */
+ public ActivityBuilder setBorderCornerOffset(float borderCornerOffset) {
+ mOptions.borderCornerOffset = borderCornerOffset;
+ return this;
+ }
+
+ /**
+ * the length of the corner line away from the corner (in pixels).
+ * Default: 14dp
+ */
+ public ActivityBuilder setBorderCornerLength(float borderCornerLength) {
+ mOptions.borderCornerLength = borderCornerLength;
+ return this;
+ }
+
+ /**
+ * the color of the corner line.
+ * Default: WHITE
+ */
+ public ActivityBuilder setBorderCornerColor(int borderCornerColor) {
+ mOptions.borderCornerColor = borderCornerColor;
+ return this;
+ }
+
+ /**
+ * the thickness of the guidelines lines (in pixels).
+ * Default: 1dp
+ */
+ public ActivityBuilder setGuidelinesThickness(float guidelinesThickness) {
+ mOptions.guidelinesThickness = guidelinesThickness;
+ return this;
+ }
+
+ /**
+ * the color of the guidelines lines.
+ * Default: Color.argb(170, 255, 255, 255)
+ */
+ public ActivityBuilder setGuidelinesColor(int guidelinesColor) {
+ mOptions.guidelinesColor = guidelinesColor;
+ return this;
+ }
+
+ /**
+ * the color of the overlay background around the crop window cover the image parts not in the
+ * crop window.
+ * Default: Color.argb(119, 0, 0, 0)
+ */
+ public ActivityBuilder setBackgroundColor(int backgroundColor) {
+ mOptions.backgroundColor = backgroundColor;
+ return this;
+ }
+
+ /**
+ * the min size the crop window is allowed to be (in pixels).
+ * Default: 42dp, 42dp
+ */
+ public ActivityBuilder setMinCropWindowSize(int minCropWindowWidth, int minCropWindowHeight) {
+ mOptions.minCropWindowWidth = minCropWindowWidth;
+ mOptions.minCropWindowHeight = minCropWindowHeight;
+ return this;
+ }
+
+ /**
+ * the min size the resulting cropping image is allowed to be, affects the cropping window
+ * limits (in pixels).
+ * Default: 40px, 40px
+ */
+ public ActivityBuilder setMinCropResultSize(int minCropResultWidth, int minCropResultHeight) {
+ mOptions.minCropResultWidth = minCropResultWidth;
+ mOptions.minCropResultHeight = minCropResultHeight;
+ return this;
+ }
+
+ /**
+ * the max size the resulting cropping image is allowed to be, affects the cropping window
+ * limits (in pixels).
+ * Default: 99999, 99999
+ */
+ public ActivityBuilder setMaxCropResultSize(int maxCropResultWidth, int maxCropResultHeight) {
+ mOptions.maxCropResultWidth = maxCropResultWidth;
+ mOptions.maxCropResultHeight = maxCropResultHeight;
+ return this;
+ }
+
+ /**
+ * the title of the {@link CropImageActivity}.
+ * Default: ""
+ */
+ public ActivityBuilder setActivityTitle(CharSequence activityTitle) {
+ mOptions.activityTitle = activityTitle;
+ return this;
+ }
+
+ /**
+ * the color to use for action bar items icons.
+ * Default: NONE
+ */
+ public ActivityBuilder setActivityMenuIconColor(int activityMenuIconColor) {
+ mOptions.activityMenuIconColor = activityMenuIconColor;
+ return this;
+ }
+
+ /**
+ * the Android Uri to save the cropped image to.
+ * Default: NONE, will create a temp file
+ */
+ public ActivityBuilder setOutputUri(Uri outputUri) {
+ mOptions.outputUri = outputUri;
+ return this;
+ }
+
+ /**
+ * the compression format to use when writting the image.
+ * Default: JPEG
+ */
+ public ActivityBuilder setOutputCompressFormat(Bitmap.CompressFormat outputCompressFormat) {
+ mOptions.outputCompressFormat = outputCompressFormat;
+ return this;
+ }
+
+ /**
+ * the quility (if applicable) to use when writting the image (0 - 100).
+ * Default: 90
+ */
+ public ActivityBuilder setOutputCompressQuality(int outputCompressQuality) {
+ mOptions.outputCompressQuality = outputCompressQuality;
+ return this;
+ }
+
+ /**
+ * the size to resize the cropped image to.
+ * Uses {@link CropImageView.RequestSizeOptions#RESIZE_INSIDE} option.
+ * Default: 0, 0 - not set, will not resize
+ */
+ public ActivityBuilder setRequestedSize(int reqWidth, int reqHeight) {
+ return setRequestedSize(reqWidth, reqHeight, CropImageView.RequestSizeOptions.RESIZE_INSIDE);
+ }
+
+ /**
+ * the size to resize the cropped image to.
+ * Default: 0, 0 - not set, will not resize
+ */
+ public ActivityBuilder setRequestedSize(
+ int reqWidth, int reqHeight, CropImageView.RequestSizeOptions options) {
+ mOptions.outputRequestWidth = reqWidth;
+ mOptions.outputRequestHeight = reqHeight;
+ mOptions.outputRequestSizeOptions = options;
+ return this;
+ }
+
+ /**
+ * if the result of crop image activity should not save the cropped image bitmap.
+ * Used if you want to crop the image manually and need only the crop rectangle and rotation
+ * data.
+ * Default: false
+ */
+ public ActivityBuilder setNoOutputImage(boolean noOutputImage) {
+ mOptions.noOutputImage = noOutputImage;
+ return this;
+ }
+
+ /**
+ * the initial rectangle to set on the cropping image after loading.
+ * Default: NONE - will initialize using initial crop window padding ratio
+ */
+ public ActivityBuilder setInitialCropWindowRectangle(Rect initialCropWindowRectangle) {
+ mOptions.initialCropWindowRectangle = initialCropWindowRectangle;
+ return this;
+ }
+
+ /**
+ * the initial rotation to set on the cropping image after loading (0-360 degrees clockwise).
+ *
+ * Default: NONE - will read image exif data
+ */
+ public ActivityBuilder setInitialRotation(int initialRotation) {
+ mOptions.initialRotation = (initialRotation + 360) % 360;
+ return this;
+ }
+
+ /**
+ * if to allow rotation during cropping.
+ * Default: true
+ */
+ public ActivityBuilder setAllowRotation(boolean allowRotation) {
+ mOptions.allowRotation = allowRotation;
+ return this;
+ }
+
+ /**
+ * if to allow flipping during cropping.
+ * Default: true
+ */
+ public ActivityBuilder setAllowFlipping(boolean allowFlipping) {
+ mOptions.allowFlipping = allowFlipping;
+ return this;
+ }
+
+ /**
+ * if to allow counter-clockwise rotation during cropping.
+ * Note: if rotation is disabled this option has no effect.
+ * Default: false
+ */
+ public ActivityBuilder setAllowCounterRotation(boolean allowCounterRotation) {
+ mOptions.allowCounterRotation = allowCounterRotation;
+ return this;
+ }
+
+ /**
+ * The amount of degreees to rotate clockwise or counter-clockwise (0-360).
+ * Default: 90
+ */
+ public ActivityBuilder setRotationDegrees(int rotationDegrees) {
+ mOptions.rotationDegrees = (rotationDegrees + 360) % 360;
+ return this;
+ }
+
+ /**
+ * whether the image should be flipped horizontally.
+ * Default: false
+ */
+ public ActivityBuilder setFlipHorizontally(boolean flipHorizontally) {
+ mOptions.flipHorizontally = flipHorizontally;
+ return this;
+ }
+
+ /**
+ * whether the image should be flipped vertically.
+ * Default: false
+ */
+ public ActivityBuilder setFlipVertically(boolean flipVertically) {
+ mOptions.flipVertically = flipVertically;
+ return this;
+ }
+
+ /**
+ * optional, set crop menu crop button title.
+ * Default: null, will use resource string: crop_image_menu_crop
+ */
+ public ActivityBuilder setCropMenuCropButtonTitle(CharSequence title) {
+ mOptions.cropMenuCropButtonTitle = title;
+ return this;
+ }
+
+ /**
+ * Image resource id to use for crop icon instead of text.
+ * Default: 0
+ */
+ public ActivityBuilder setCropMenuCropButtonIcon(@DrawableRes int drawableResource) {
+ mOptions.cropMenuCropButtonIcon = drawableResource;
+ return this;
+ }
+ }
+ // endregion
+
+ // region: Inner class: ActivityResult
+
+ /** Result data of Crop Image Activity. */
+ public static final class ActivityResult extends CropImageView.CropResult implements Parcelable {
+
+ public static final Creator CREATOR =
+ new Creator() {
+ @Override
+ public ActivityResult createFromParcel(Parcel in) {
+ return new ActivityResult(in);
+ }
+
+ @Override
+ public ActivityResult[] newArray(int size) {
+ return new ActivityResult[size];
+ }
+ };
+
+ public ActivityResult(
+ Uri originalUri,
+ Uri uri,
+ Exception error,
+ float[] cropPoints,
+ Rect cropRect,
+ int rotation,
+ Rect wholeImageRect,
+ int sampleSize) {
+ super(
+ null,
+ originalUri,
+ null,
+ uri,
+ error,
+ cropPoints,
+ cropRect,
+ wholeImageRect,
+ rotation,
+ sampleSize);
+ }
+
+ protected ActivityResult(Parcel in) {
+ super(
+ null,
+ (Uri) in.readParcelable(Uri.class.getClassLoader()),
+ null,
+ (Uri) in.readParcelable(Uri.class.getClassLoader()),
+ (Exception) in.readSerializable(),
+ in.createFloatArray(),
+ (Rect) in.readParcelable(Rect.class.getClassLoader()),
+ (Rect) in.readParcelable(Rect.class.getClassLoader()),
+ in.readInt(),
+ in.readInt());
+ }
+
+ @Override
+ public void writeToParcel(Parcel dest, int flags) {
+ dest.writeParcelable(getOriginalUri(), flags);
+ dest.writeParcelable(getUri(), flags);
+ dest.writeSerializable(getError());
+ dest.writeFloatArray(getCropPoints());
+ dest.writeParcelable(getCropRect(), flags);
+ dest.writeParcelable(getWholeImageRect(), flags);
+ dest.writeInt(getRotation());
+ dest.writeInt(getSampleSize());
+ }
+
+ @Override
+ public int describeContents() {
+ return 0;
+ }
+ }
+ // endregion
+}
diff --git a/cropper/src/main/java/com/theartofdev/edmodo/cropper/CropImageActivity.java b/cropper/src/main/java/com/theartofdev/edmodo/cropper/CropImageActivity.java
new file mode 100644
index 0000000..d1afa9f
--- /dev/null
+++ b/cropper/src/main/java/com/theartofdev/edmodo/cropper/CropImageActivity.java
@@ -0,0 +1,350 @@
+// "Therefore those skilled at the unorthodox
+// are infinite as heaven and earth,
+// inexhaustible as the great rivers.
+// When they come to an end,
+// they begin again,
+// like the days and months;
+// they die and are reborn,
+// like the four seasons."
+//
+// - Sun Tsu,
+// "The Art of War"
+
+package com.theartofdev.edmodo.cropper;
+
+import android.Manifest;
+import android.annotation.SuppressLint;
+import android.app.Activity;
+import android.content.Intent;
+import android.content.pm.PackageManager;
+import android.graphics.Bitmap;
+import android.graphics.PorterDuff;
+import android.graphics.drawable.Drawable;
+import android.net.Uri;
+import android.os.Bundle;
+import androidx.annotation.NonNull;
+import androidx.core.content.ContextCompat;
+import androidx.appcompat.app.ActionBar;
+import androidx.appcompat.app.AppCompatActivity;
+import android.util.Log;
+import android.view.Menu;
+import android.view.MenuItem;
+import android.widget.Toast;
+
+import java.io.File;
+import java.io.IOException;
+
+/**
+ * Built-in activity for image cropping.
+ * Use {@link CropImage#activity(Uri)} to create a builder to start this activity.
+ */
+public class CropImageActivity extends AppCompatActivity
+ implements CropImageView.OnSetImageUriCompleteListener,
+ CropImageView.OnCropImageCompleteListener {
+
+ /** The crop image view library widget used in the activity */
+ private CropImageView mCropImageView;
+
+ /** Persist URI image to crop URI if specific permissions are required */
+ private Uri mCropImageUri;
+
+ /** the options that were set for the crop image */
+ private CropImageOptions mOptions;
+
+ @Override
+ @SuppressLint("NewApi")
+ public void onCreate(Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+ setContentView(R.layout.crop_image_activity);
+
+ mCropImageView = findViewById(R.id.cropImageView);
+
+ Bundle bundle = getIntent().getBundleExtra(CropImage.CROP_IMAGE_EXTRA_BUNDLE);
+ mCropImageUri = bundle.getParcelable(CropImage.CROP_IMAGE_EXTRA_SOURCE);
+ mOptions = bundle.getParcelable(CropImage.CROP_IMAGE_EXTRA_OPTIONS);
+
+ if (savedInstanceState == null) {
+ if (mCropImageUri == null || mCropImageUri.equals(Uri.EMPTY)) {
+ if (CropImage.isExplicitCameraPermissionRequired(this)) {
+ // request permissions and handle the result in onRequestPermissionsResult()
+ requestPermissions(
+ new String[] {Manifest.permission.CAMERA},
+ CropImage.CAMERA_CAPTURE_PERMISSIONS_REQUEST_CODE);
+ } else {
+ CropImage.startPickImageActivity(this);
+ }
+ } else if (CropImage.isReadExternalStoragePermissionsRequired(this, mCropImageUri)) {
+ // request permissions and handle the result in onRequestPermissionsResult()
+ requestPermissions(
+ new String[] {Manifest.permission.READ_EXTERNAL_STORAGE},
+ CropImage.PICK_IMAGE_PERMISSIONS_REQUEST_CODE);
+ } else {
+ // no permissions required or already grunted, can start crop image activity
+ mCropImageView.setImageUriAsync(mCropImageUri);
+ }
+ }
+
+ ActionBar actionBar = getSupportActionBar();
+ if (actionBar != null) {
+ CharSequence title = mOptions != null &&
+ mOptions.activityTitle != null && mOptions.activityTitle.length() > 0
+ ? mOptions.activityTitle
+ : getResources().getString(R.string.crop_image_activity_title);
+ actionBar.setTitle(title);
+ actionBar.setDisplayHomeAsUpEnabled(true);
+ }
+ }
+
+ @Override
+ protected void onStart() {
+ super.onStart();
+ mCropImageView.setOnSetImageUriCompleteListener(this);
+ mCropImageView.setOnCropImageCompleteListener(this);
+ }
+
+ @Override
+ protected void onStop() {
+ super.onStop();
+ mCropImageView.setOnSetImageUriCompleteListener(null);
+ mCropImageView.setOnCropImageCompleteListener(null);
+ }
+
+ @Override
+ public boolean onCreateOptionsMenu(Menu menu) {
+ getMenuInflater().inflate(R.menu.crop_image_menu, menu);
+
+ if (!mOptions.allowRotation) {
+ menu.removeItem(R.id.crop_image_menu_rotate_left);
+ menu.removeItem(R.id.crop_image_menu_rotate_right);
+ } else if (mOptions.allowCounterRotation) {
+ menu.findItem(R.id.crop_image_menu_rotate_left).setVisible(true);
+ }
+
+ if (!mOptions.allowFlipping) {
+ menu.removeItem(R.id.crop_image_menu_flip);
+ }
+
+ if (mOptions.cropMenuCropButtonTitle != null) {
+ menu.findItem(R.id.crop_image_menu_crop).setTitle(mOptions.cropMenuCropButtonTitle);
+ }
+
+ Drawable cropIcon = null;
+ try {
+ if (mOptions.cropMenuCropButtonIcon != 0) {
+ cropIcon = ContextCompat.getDrawable(this, mOptions.cropMenuCropButtonIcon);
+ menu.findItem(R.id.crop_image_menu_crop).setIcon(cropIcon);
+ }
+ } catch (Exception e) {
+ Log.w("AIC", "Failed to read menu crop drawable", e);
+ }
+
+ if (mOptions.activityMenuIconColor != 0) {
+ updateMenuItemIconColor(
+ menu, R.id.crop_image_menu_rotate_left, mOptions.activityMenuIconColor);
+ updateMenuItemIconColor(
+ menu, R.id.crop_image_menu_rotate_right, mOptions.activityMenuIconColor);
+ updateMenuItemIconColor(menu, R.id.crop_image_menu_flip, mOptions.activityMenuIconColor);
+ if (cropIcon != null) {
+ updateMenuItemIconColor(menu, R.id.crop_image_menu_crop, mOptions.activityMenuIconColor);
+ }
+ }
+ return true;
+ }
+
+ @Override
+ public boolean onOptionsItemSelected(MenuItem item) {
+ if (item.getItemId() == R.id.crop_image_menu_crop) {
+ cropImage();
+ return true;
+ }
+ if (item.getItemId() == R.id.crop_image_menu_rotate_left) {
+ rotateImage(-mOptions.rotationDegrees);
+ return true;
+ }
+ if (item.getItemId() == R.id.crop_image_menu_rotate_right) {
+ rotateImage(mOptions.rotationDegrees);
+ return true;
+ }
+ if (item.getItemId() == R.id.crop_image_menu_flip_horizontally) {
+ mCropImageView.flipImageHorizontally();
+ return true;
+ }
+ if (item.getItemId() == R.id.crop_image_menu_flip_vertically) {
+ mCropImageView.flipImageVertically();
+ return true;
+ }
+ if (item.getItemId() == android.R.id.home) {
+ setResultCancel();
+ return true;
+ }
+ return super.onOptionsItemSelected(item);
+ }
+
+ @Override
+ public void onBackPressed() {
+ super.onBackPressed();
+ setResultCancel();
+ }
+
+ @Override
+ @SuppressLint("NewApi")
+ protected void onActivityResult(int requestCode, int resultCode, Intent data) {
+
+ // handle result of pick image chooser
+ if (requestCode == CropImage.PICK_IMAGE_CHOOSER_REQUEST_CODE) {
+ if (resultCode == Activity.RESULT_CANCELED) {
+ // User cancelled the picker. We don't have anything to crop
+ setResultCancel();
+ }
+
+ if (resultCode == Activity.RESULT_OK) {
+ mCropImageUri = CropImage.getPickImageResultUri(this, data);
+
+ // For API >= 23 we need to check specifically that we have permissions to read external
+ // storage.
+ if (CropImage.isReadExternalStoragePermissionsRequired(this, mCropImageUri)) {
+ // request permissions and handle the result in onRequestPermissionsResult()
+ requestPermissions(
+ new String[] {Manifest.permission.READ_EXTERNAL_STORAGE},
+ CropImage.PICK_IMAGE_PERMISSIONS_REQUEST_CODE);
+ } else {
+ // no permissions required or already grunted, can start crop image activity
+ mCropImageView.setImageUriAsync(mCropImageUri);
+ }
+ }
+ }
+ }
+
+ @Override
+ public void onRequestPermissionsResult(
+ int requestCode, @NonNull String permissions[], @NonNull int[] grantResults) {
+ if (requestCode == CropImage.PICK_IMAGE_PERMISSIONS_REQUEST_CODE) {
+ if (mCropImageUri != null
+ && grantResults.length > 0
+ && grantResults[0] == PackageManager.PERMISSION_GRANTED) {
+ // required permissions granted, start crop image activity
+ mCropImageView.setImageUriAsync(mCropImageUri);
+ } else {
+ Toast.makeText(this, R.string.crop_image_activity_no_permissions, Toast.LENGTH_LONG).show();
+ setResultCancel();
+ }
+ }
+
+ if (requestCode == CropImage.CAMERA_CAPTURE_PERMISSIONS_REQUEST_CODE) {
+ // Irrespective of whether camera permission was given or not, we show the picker
+ // The picker will not add the camera intent if permission is not available
+ CropImage.startPickImageActivity(this);
+ }
+ }
+
+ @Override
+ public void onSetImageUriComplete(CropImageView view, Uri uri, Exception error) {
+ if (error == null) {
+ if (mOptions.initialCropWindowRectangle != null) {
+ mCropImageView.setCropRect(mOptions.initialCropWindowRectangle);
+ }
+ if (mOptions.initialRotation > -1) {
+ mCropImageView.setRotatedDegrees(mOptions.initialRotation);
+ }
+ } else {
+ setResult(null, error, 1);
+ }
+ }
+
+ @Override
+ public void onCropImageComplete(CropImageView view, CropImageView.CropResult result) {
+ setResult(result.getUri(), result.getError(), result.getSampleSize());
+ }
+
+ // region: Private methods
+
+ /** Execute crop image and save the result tou output uri. */
+ protected void cropImage() {
+ if (mOptions.noOutputImage) {
+ setResult(null, null, 1);
+ } else {
+ Uri outputUri = getOutputUri();
+ mCropImageView.saveCroppedImageAsync(
+ outputUri,
+ mOptions.outputCompressFormat,
+ mOptions.outputCompressQuality,
+ mOptions.outputRequestWidth,
+ mOptions.outputRequestHeight,
+ mOptions.outputRequestSizeOptions);
+ }
+ }
+
+ /** Rotate the image in the crop image view. */
+ protected void rotateImage(int degrees) {
+ mCropImageView.rotateImage(degrees);
+ }
+
+ /**
+ * Get Android uri to save the cropped image into.
+ * Use the given in options or create a temp file.
+ */
+ protected Uri getOutputUri() {
+ Uri outputUri = mOptions.outputUri;
+ if (outputUri == null || outputUri.equals(Uri.EMPTY)) {
+ try {
+ String ext =
+ mOptions.outputCompressFormat == Bitmap.CompressFormat.JPEG
+ ? ".jpg"
+ : mOptions.outputCompressFormat == Bitmap.CompressFormat.PNG ? ".png" : ".webp";
+ outputUri = Uri.fromFile(File.createTempFile("cropped", ext, getCacheDir()));
+ } catch (IOException e) {
+ throw new RuntimeException("Failed to create temp file for output image", e);
+ }
+ }
+ return outputUri;
+ }
+
+ /** Result with cropped image data or error if failed. */
+ protected void setResult(Uri uri, Exception error, int sampleSize) {
+ int resultCode = error == null ? RESULT_OK : CropImage.CROP_IMAGE_ACTIVITY_RESULT_ERROR_CODE;
+ setResult(resultCode, getResultIntent(uri, error, sampleSize));
+ finish();
+ }
+
+ /** Cancel of cropping activity. */
+ protected void setResultCancel() {
+ setResult(RESULT_CANCELED);
+ finish();
+ }
+
+ /** Get intent instance to be used for the result of this activity. */
+ protected Intent getResultIntent(Uri uri, Exception error, int sampleSize) {
+ CropImage.ActivityResult result =
+ new CropImage.ActivityResult(
+ mCropImageView.getImageUri(),
+ uri,
+ error,
+ mCropImageView.getCropPoints(),
+ mCropImageView.getCropRect(),
+ mCropImageView.getRotatedDegrees(),
+ mCropImageView.getWholeImageRect(),
+ sampleSize);
+ Intent intent = new Intent();
+ intent.putExtras(getIntent());
+ intent.putExtra(CropImage.CROP_IMAGE_EXTRA_RESULT, result);
+ return intent;
+ }
+
+ /** Update the color of a specific menu item to the given color. */
+ private void updateMenuItemIconColor(Menu menu, int itemId, int color) {
+ MenuItem menuItem = menu.findItem(itemId);
+ if (menuItem != null) {
+ Drawable menuItemIcon = menuItem.getIcon();
+ if (menuItemIcon != null) {
+ try {
+ menuItemIcon.mutate();
+ menuItemIcon.setColorFilter(color, PorterDuff.Mode.SRC_ATOP);
+ menuItem.setIcon(menuItemIcon);
+ } catch (Exception e) {
+ Log.w("AIC", "Failed to update menu item color", e);
+ }
+ }
+ }
+ }
+ // endregion
+}
diff --git a/cropper/src/main/java/com/theartofdev/edmodo/cropper/CropImageAnimation.java b/cropper/src/main/java/com/theartofdev/edmodo/cropper/CropImageAnimation.java
new file mode 100644
index 0000000..0acb89a
--- /dev/null
+++ b/cropper/src/main/java/com/theartofdev/edmodo/cropper/CropImageAnimation.java
@@ -0,0 +1,121 @@
+// "Therefore those skilled at the unorthodox
+// are infinite as heaven and earth,
+// inexhaustible as the great rivers.
+// When they come to an end,
+// they begin again,
+// like the days and months;
+// they die and are reborn,
+// like the four seasons."
+//
+// - Sun Tsu,
+// "The Art of War"
+
+package com.theartofdev.edmodo.cropper;
+
+import android.graphics.Matrix;
+import android.graphics.RectF;
+import android.view.animation.AccelerateDecelerateInterpolator;
+import android.view.animation.Animation;
+import android.view.animation.Transformation;
+import android.widget.ImageView;
+
+/**
+ * Animation to handle smooth cropping image matrix transformation change, specifically for
+ * zoom-in/out.
+ */
+final class CropImageAnimation extends Animation implements Animation.AnimationListener {
+
+ // region: Fields and Consts
+
+ private final ImageView mImageView;
+
+ private final CropOverlayView mCropOverlayView;
+
+ private final float[] mStartBoundPoints = new float[8];
+
+ private final float[] mEndBoundPoints = new float[8];
+
+ private final RectF mStartCropWindowRect = new RectF();
+
+ private final RectF mEndCropWindowRect = new RectF();
+
+ private final float[] mStartImageMatrix = new float[9];
+
+ private final float[] mEndImageMatrix = new float[9];
+
+ private final RectF mAnimRect = new RectF();
+
+ private final float[] mAnimPoints = new float[8];
+
+ private final float[] mAnimMatrix = new float[9];
+ // endregion
+
+ public CropImageAnimation(ImageView cropImageView, CropOverlayView cropOverlayView) {
+ mImageView = cropImageView;
+ mCropOverlayView = cropOverlayView;
+
+ setDuration(300);
+ setFillAfter(true);
+ setInterpolator(new AccelerateDecelerateInterpolator());
+ setAnimationListener(this);
+ }
+
+ public void setStartState(float[] boundPoints, Matrix imageMatrix) {
+ reset();
+ System.arraycopy(boundPoints, 0, mStartBoundPoints, 0, 8);
+ mStartCropWindowRect.set(mCropOverlayView.getCropWindowRect());
+ imageMatrix.getValues(mStartImageMatrix);
+ }
+
+ public void setEndState(float[] boundPoints, Matrix imageMatrix) {
+ System.arraycopy(boundPoints, 0, mEndBoundPoints, 0, 8);
+ mEndCropWindowRect.set(mCropOverlayView.getCropWindowRect());
+ imageMatrix.getValues(mEndImageMatrix);
+ }
+
+ @Override
+ protected void applyTransformation(float interpolatedTime, Transformation t) {
+
+ mAnimRect.left =
+ mStartCropWindowRect.left
+ + (mEndCropWindowRect.left - mStartCropWindowRect.left) * interpolatedTime;
+ mAnimRect.top =
+ mStartCropWindowRect.top
+ + (mEndCropWindowRect.top - mStartCropWindowRect.top) * interpolatedTime;
+ mAnimRect.right =
+ mStartCropWindowRect.right
+ + (mEndCropWindowRect.right - mStartCropWindowRect.right) * interpolatedTime;
+ mAnimRect.bottom =
+ mStartCropWindowRect.bottom
+ + (mEndCropWindowRect.bottom - mStartCropWindowRect.bottom) * interpolatedTime;
+ mCropOverlayView.setCropWindowRect(mAnimRect);
+
+ for (int i = 0; i < mAnimPoints.length; i++) {
+ mAnimPoints[i] =
+ mStartBoundPoints[i] + (mEndBoundPoints[i] - mStartBoundPoints[i]) * interpolatedTime;
+ }
+ mCropOverlayView.setBounds(mAnimPoints, mImageView.getWidth(), mImageView.getHeight());
+
+ for (int i = 0; i < mAnimMatrix.length; i++) {
+ mAnimMatrix[i] =
+ mStartImageMatrix[i] + (mEndImageMatrix[i] - mStartImageMatrix[i]) * interpolatedTime;
+ }
+ Matrix m = mImageView.getImageMatrix();
+ m.setValues(mAnimMatrix);
+ mImageView.setImageMatrix(m);
+
+ mImageView.invalidate();
+ mCropOverlayView.invalidate();
+ }
+
+ @Override
+ public void onAnimationStart(Animation animation) {}
+
+ @Override
+ public void onAnimationEnd(Animation animation) {
+ mImageView.clearAnimation();
+ }
+
+ @Override
+ public void onAnimationRepeat(Animation animation) {}
+}
diff --git a/cropper/src/main/java/com/theartofdev/edmodo/cropper/CropImageOptions.java b/cropper/src/main/java/com/theartofdev/edmodo/cropper/CropImageOptions.java
new file mode 100644
index 0000000..240fe9b
--- /dev/null
+++ b/cropper/src/main/java/com/theartofdev/edmodo/cropper/CropImageOptions.java
@@ -0,0 +1,463 @@
+// "Therefore those skilled at the unorthodox
+// are infinite as heaven and earth;
+// inexhaustible as the great rivers.
+// When they come to an end;
+// they begin again;
+// like the days and months;
+// they die and are reborn;
+// like the four seasons."
+//
+// - Sun Tsu;
+// "The Art of War"
+
+package com.theartofdev.edmodo.cropper;
+
+import android.content.res.Resources;
+import android.graphics.Bitmap;
+import android.graphics.Color;
+import android.graphics.Rect;
+import android.net.Uri;
+import android.os.Parcel;
+import android.os.Parcelable;
+import android.text.TextUtils;
+import android.util.DisplayMetrics;
+import android.util.TypedValue;
+
+/**
+ * All the possible options that can be set to customize crop image.
+ * Initialized with default values.
+ */
+public class CropImageOptions implements Parcelable {
+
+ public static final Creator CREATOR =
+ new Creator() {
+ @Override
+ public CropImageOptions createFromParcel(Parcel in) {
+ return new CropImageOptions(in);
+ }
+
+ @Override
+ public CropImageOptions[] newArray(int size) {
+ return new CropImageOptions[size];
+ }
+ };
+
+ /** The shape of the cropping window. */
+ public CropImageView.CropShape cropShape;
+
+ /**
+ * An edge of the crop window will snap to the corresponding edge of a specified bounding box when
+ * the crop window edge is less than or equal to this distance (in pixels) away from the bounding
+ * box edge. (in pixels)
+ */
+ public float snapRadius;
+
+ /**
+ * The radius of the touchable area around the handle. (in pixels)
+ * We are basing this value off of the recommended 48dp Rhythm.
+ * See: http://developer.android.com/design/style/metrics-grids.html#48dp-rhythm
+ */
+ public float touchRadius;
+
+ /** whether the guidelines should be on, off, or only showing when resizing. */
+ public CropImageView.Guidelines guidelines;
+
+ /** The initial scale type of the image in the crop image view */
+ public CropImageView.ScaleType scaleType;
+
+ /**
+ * if to show crop overlay UI what contains the crop window UI surrounded by background over the
+ * cropping image.
+ * default: true, may disable for animation or frame transition.
+ */
+ public boolean showCropOverlay;
+
+ /**
+ * if to show progress bar when image async loading/cropping is in progress.
+ * default: true, disable to provide custom progress bar UI.
+ */
+ public boolean showProgressBar;
+
+ /**
+ * if auto-zoom functionality is enabled.
+ * default: true.
+ */
+ public boolean autoZoomEnabled;
+
+ /** if multi-touch should be enabled on the crop box default: false */
+ public boolean multiTouchEnabled;
+
+ /** The max zoom allowed during cropping. */
+ public int maxZoom;
+
+ /**
+ * The initial crop window padding from image borders in percentage of the cropping image
+ * dimensions.
+ */
+ public float initialCropWindowPaddingRatio;
+
+ /** whether the width to height aspect ratio should be maintained or free to change. */
+ public boolean fixAspectRatio;
+
+ /** the X value of the aspect ratio. */
+ public int aspectRatioX;
+
+ /** the Y value of the aspect ratio. */
+ public int aspectRatioY;
+
+ /** the thickness of the guidelines lines in pixels. (in pixels) */
+ public float borderLineThickness;
+
+ /** the color of the guidelines lines */
+ public int borderLineColor;
+
+ /** thickness of the corner line. (in pixels) */
+ public float borderCornerThickness;
+
+ /** the offset of corner line from crop window border. (in pixels) */
+ public float borderCornerOffset;
+
+ /** the length of the corner line away from the corner. (in pixels) */
+ public float borderCornerLength;
+
+ /** the color of the corner line */
+ public int borderCornerColor;
+
+ /** the thickness of the guidelines lines. (in pixels) */
+ public float guidelinesThickness;
+
+ /** the color of the guidelines lines */
+ public int guidelinesColor;
+
+ /**
+ * the color of the overlay background around the crop window cover the image parts not in the
+ * crop window.
+ */
+ public int backgroundColor;
+
+ /** the min width the crop window is allowed to be. (in pixels) */
+ public int minCropWindowWidth;
+
+ /** the min height the crop window is allowed to be. (in pixels) */
+ public int minCropWindowHeight;
+
+ /**
+ * the min width the resulting cropping image is allowed to be, affects the cropping window
+ * limits. (in pixels)
+ */
+ public int minCropResultWidth;
+
+ /**
+ * the min height the resulting cropping image is allowed to be, affects the cropping window
+ * limits. (in pixels)
+ */
+ public int minCropResultHeight;
+
+ /**
+ * the max width the resulting cropping image is allowed to be, affects the cropping window
+ * limits. (in pixels)
+ */
+ public int maxCropResultWidth;
+
+ /**
+ * the max height the resulting cropping image is allowed to be, affects the cropping window
+ * limits. (in pixels)
+ */
+ public int maxCropResultHeight;
+
+ /** the title of the {@link CropImageActivity} */
+ public CharSequence activityTitle;
+
+ /** the color to use for action bar items icons */
+ public int activityMenuIconColor;
+
+ /** the Android Uri to save the cropped image to */
+ public Uri outputUri;
+
+ /** the compression format to use when writing the image */
+ public Bitmap.CompressFormat outputCompressFormat;
+
+ /** the quality (if applicable) to use when writing the image (0 - 100) */
+ public int outputCompressQuality;
+
+ /** the width to resize the cropped image to (see options) */
+ public int outputRequestWidth;
+
+ /** the height to resize the cropped image to (see options) */
+ public int outputRequestHeight;
+
+ /** the resize method to use on the cropped bitmap (see options documentation) */
+ public CropImageView.RequestSizeOptions outputRequestSizeOptions;
+
+ /** if the result of crop image activity should not save the cropped image bitmap */
+ public boolean noOutputImage;
+
+ /** the initial rectangle to set on the cropping image after loading */
+ public Rect initialCropWindowRectangle;
+
+ /** the initial rotation to set on the cropping image after loading (0-360 degrees clockwise) */
+ public int initialRotation;
+
+ /** if to allow (all) rotation during cropping (activity) */
+ public boolean allowRotation;
+
+ /** if to allow (all) flipping during cropping (activity) */
+ public boolean allowFlipping;
+
+ /** if to allow counter-clockwise rotation during cropping (activity) */
+ public boolean allowCounterRotation;
+
+ /** the amount of degrees to rotate clockwise or counter-clockwise */
+ public int rotationDegrees;
+
+ /** whether the image should be flipped horizontally */
+ public boolean flipHorizontally;
+
+ /** whether the image should be flipped vertically */
+ public boolean flipVertically;
+
+ /** optional, the text of the crop menu crop button */
+ public CharSequence cropMenuCropButtonTitle;
+
+ /** optional image resource to be used for crop menu crop icon instead of text */
+ public int cropMenuCropButtonIcon;
+
+ /** Init options with defaults. */
+ public CropImageOptions() {
+
+ DisplayMetrics dm = Resources.getSystem().getDisplayMetrics();
+
+ cropShape = CropImageView.CropShape.RECTANGLE;
+ snapRadius = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 3, dm);
+ touchRadius = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 24, dm);
+ guidelines = CropImageView.Guidelines.ON_TOUCH;
+ scaleType = CropImageView.ScaleType.FIT_CENTER;
+ showCropOverlay = true;
+ showProgressBar = true;
+ autoZoomEnabled = true;
+ multiTouchEnabled = false;
+ maxZoom = 4;
+ initialCropWindowPaddingRatio = 0.1f;
+
+ fixAspectRatio = false;
+ aspectRatioX = 1;
+ aspectRatioY = 1;
+
+ borderLineThickness = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 3, dm);
+ borderLineColor = Color.argb(170, 255, 255, 255);
+ borderCornerThickness = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 2, dm);
+ borderCornerOffset = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 5, dm);
+ borderCornerLength = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 14, dm);
+ borderCornerColor = Color.WHITE;
+
+ guidelinesThickness = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 1, dm);
+ guidelinesColor = Color.argb(170, 255, 255, 255);
+ backgroundColor = Color.argb(119, 0, 0, 0);
+
+ minCropWindowWidth = (int) TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 42, dm);
+ minCropWindowHeight = (int) TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 42, dm);
+ minCropResultWidth = 40;
+ minCropResultHeight = 40;
+ maxCropResultWidth = 99999;
+ maxCropResultHeight = 99999;
+
+ activityTitle = "";
+ activityMenuIconColor = 0;
+
+ outputUri = Uri.EMPTY;
+ outputCompressFormat = Bitmap.CompressFormat.JPEG;
+ outputCompressQuality = 90;
+ outputRequestWidth = 0;
+ outputRequestHeight = 0;
+ outputRequestSizeOptions = CropImageView.RequestSizeOptions.NONE;
+ noOutputImage = false;
+
+ initialCropWindowRectangle = null;
+ initialRotation = -1;
+ allowRotation = true;
+ allowFlipping = true;
+ allowCounterRotation = false;
+ rotationDegrees = 90;
+ flipHorizontally = false;
+ flipVertically = false;
+ cropMenuCropButtonTitle = null;
+
+ cropMenuCropButtonIcon = 0;
+ }
+
+ /** Create object from parcel. */
+ protected CropImageOptions(Parcel in) {
+ cropShape = CropImageView.CropShape.values()[in.readInt()];
+ snapRadius = in.readFloat();
+ touchRadius = in.readFloat();
+ guidelines = CropImageView.Guidelines.values()[in.readInt()];
+ scaleType = CropImageView.ScaleType.values()[in.readInt()];
+ showCropOverlay = in.readByte() != 0;
+ showProgressBar = in.readByte() != 0;
+ autoZoomEnabled = in.readByte() != 0;
+ multiTouchEnabled = in.readByte() != 0;
+ maxZoom = in.readInt();
+ initialCropWindowPaddingRatio = in.readFloat();
+ fixAspectRatio = in.readByte() != 0;
+ aspectRatioX = in.readInt();
+ aspectRatioY = in.readInt();
+ borderLineThickness = in.readFloat();
+ borderLineColor = in.readInt();
+ borderCornerThickness = in.readFloat();
+ borderCornerOffset = in.readFloat();
+ borderCornerLength = in.readFloat();
+ borderCornerColor = in.readInt();
+ guidelinesThickness = in.readFloat();
+ guidelinesColor = in.readInt();
+ backgroundColor = in.readInt();
+ minCropWindowWidth = in.readInt();
+ minCropWindowHeight = in.readInt();
+ minCropResultWidth = in.readInt();
+ minCropResultHeight = in.readInt();
+ maxCropResultWidth = in.readInt();
+ maxCropResultHeight = in.readInt();
+ activityTitle = TextUtils.CHAR_SEQUENCE_CREATOR.createFromParcel(in);
+ activityMenuIconColor = in.readInt();
+ outputUri = in.readParcelable(Uri.class.getClassLoader());
+ outputCompressFormat = Bitmap.CompressFormat.valueOf(in.readString());
+ outputCompressQuality = in.readInt();
+ outputRequestWidth = in.readInt();
+ outputRequestHeight = in.readInt();
+ outputRequestSizeOptions = CropImageView.RequestSizeOptions.values()[in.readInt()];
+ noOutputImage = in.readByte() != 0;
+ initialCropWindowRectangle = in.readParcelable(Rect.class.getClassLoader());
+ initialRotation = in.readInt();
+ allowRotation = in.readByte() != 0;
+ allowFlipping = in.readByte() != 0;
+ allowCounterRotation = in.readByte() != 0;
+ rotationDegrees = in.readInt();
+ flipHorizontally = in.readByte() != 0;
+ flipVertically = in.readByte() != 0;
+ cropMenuCropButtonTitle = TextUtils.CHAR_SEQUENCE_CREATOR.createFromParcel(in);
+ cropMenuCropButtonIcon = in.readInt();
+ }
+
+ @Override
+ public void writeToParcel(Parcel dest, int flags) {
+ dest.writeInt(cropShape.ordinal());
+ dest.writeFloat(snapRadius);
+ dest.writeFloat(touchRadius);
+ dest.writeInt(guidelines.ordinal());
+ dest.writeInt(scaleType.ordinal());
+ dest.writeByte((byte) (showCropOverlay ? 1 : 0));
+ dest.writeByte((byte) (showProgressBar ? 1 : 0));
+ dest.writeByte((byte) (autoZoomEnabled ? 1 : 0));
+ dest.writeByte((byte) (multiTouchEnabled ? 1 : 0));
+ dest.writeInt(maxZoom);
+ dest.writeFloat(initialCropWindowPaddingRatio);
+ dest.writeByte((byte) (fixAspectRatio ? 1 : 0));
+ dest.writeInt(aspectRatioX);
+ dest.writeInt(aspectRatioY);
+ dest.writeFloat(borderLineThickness);
+ dest.writeInt(borderLineColor);
+ dest.writeFloat(borderCornerThickness);
+ dest.writeFloat(borderCornerOffset);
+ dest.writeFloat(borderCornerLength);
+ dest.writeInt(borderCornerColor);
+ dest.writeFloat(guidelinesThickness);
+ dest.writeInt(guidelinesColor);
+ dest.writeInt(backgroundColor);
+ dest.writeInt(minCropWindowWidth);
+ dest.writeInt(minCropWindowHeight);
+ dest.writeInt(minCropResultWidth);
+ dest.writeInt(minCropResultHeight);
+ dest.writeInt(maxCropResultWidth);
+ dest.writeInt(maxCropResultHeight);
+ TextUtils.writeToParcel(activityTitle, dest, flags);
+ dest.writeInt(activityMenuIconColor);
+ dest.writeParcelable(outputUri, flags);
+ dest.writeString(outputCompressFormat.name());
+ dest.writeInt(outputCompressQuality);
+ dest.writeInt(outputRequestWidth);
+ dest.writeInt(outputRequestHeight);
+ dest.writeInt(outputRequestSizeOptions.ordinal());
+ dest.writeInt(noOutputImage ? 1 : 0);
+ dest.writeParcelable(initialCropWindowRectangle, flags);
+ dest.writeInt(initialRotation);
+ dest.writeByte((byte) (allowRotation ? 1 : 0));
+ dest.writeByte((byte) (allowFlipping ? 1 : 0));
+ dest.writeByte((byte) (allowCounterRotation ? 1 : 0));
+ dest.writeInt(rotationDegrees);
+ dest.writeByte((byte) (flipHorizontally ? 1 : 0));
+ dest.writeByte((byte) (flipVertically ? 1 : 0));
+ TextUtils.writeToParcel(cropMenuCropButtonTitle, dest, flags);
+ dest.writeInt(cropMenuCropButtonIcon);
+ }
+
+ @Override
+ public int describeContents() {
+ return 0;
+ }
+
+ /**
+ * Validate all the options are withing valid range.
+ *
+ * @throws IllegalArgumentException if any of the options is not valid
+ */
+ public void validate() {
+ if (maxZoom < 0) {
+ throw new IllegalArgumentException("Cannot set max zoom to a number < 1");
+ }
+ if (touchRadius < 0) {
+ throw new IllegalArgumentException("Cannot set touch radius value to a number <= 0 ");
+ }
+ if (initialCropWindowPaddingRatio < 0 || initialCropWindowPaddingRatio >= 0.5) {
+ throw new IllegalArgumentException(
+ "Cannot set initial crop window padding value to a number < 0 or >= 0.5");
+ }
+ if (aspectRatioX <= 0) {
+ throw new IllegalArgumentException(
+ "Cannot set aspect ratio value to a number less than or equal to 0.");
+ }
+ if (aspectRatioY <= 0) {
+ throw new IllegalArgumentException(
+ "Cannot set aspect ratio value to a number less than or equal to 0.");
+ }
+ if (borderLineThickness < 0) {
+ throw new IllegalArgumentException(
+ "Cannot set line thickness value to a number less than 0.");
+ }
+ if (borderCornerThickness < 0) {
+ throw new IllegalArgumentException(
+ "Cannot set corner thickness value to a number less than 0.");
+ }
+ if (guidelinesThickness < 0) {
+ throw new IllegalArgumentException(
+ "Cannot set guidelines thickness value to a number less than 0.");
+ }
+ if (minCropWindowHeight < 0) {
+ throw new IllegalArgumentException(
+ "Cannot set min crop window height value to a number < 0 ");
+ }
+ if (minCropResultWidth < 0) {
+ throw new IllegalArgumentException("Cannot set min crop result width value to a number < 0 ");
+ }
+ if (minCropResultHeight < 0) {
+ throw new IllegalArgumentException(
+ "Cannot set min crop result height value to a number < 0 ");
+ }
+ if (maxCropResultWidth < minCropResultWidth) {
+ throw new IllegalArgumentException(
+ "Cannot set max crop result width to smaller value than min crop result width");
+ }
+ if (maxCropResultHeight < minCropResultHeight) {
+ throw new IllegalArgumentException(
+ "Cannot set max crop result height to smaller value than min crop result height");
+ }
+ if (outputRequestWidth < 0) {
+ throw new IllegalArgumentException("Cannot set request width value to a number < 0 ");
+ }
+ if (outputRequestHeight < 0) {
+ throw new IllegalArgumentException("Cannot set request height value to a number < 0 ");
+ }
+ if (rotationDegrees < 0 || rotationDegrees > 360) {
+ throw new IllegalArgumentException(
+ "Cannot set rotation degrees value to a number < 0 or > 360");
+ }
+ }
+}
diff --git a/cropper/src/main/java/com/theartofdev/edmodo/cropper/CropImageView.java b/cropper/src/main/java/com/theartofdev/edmodo/cropper/CropImageView.java
new file mode 100644
index 0000000..77b1861
--- /dev/null
+++ b/cropper/src/main/java/com/theartofdev/edmodo/cropper/CropImageView.java
@@ -0,0 +1,2135 @@
+// "Therefore those skilled at the unorthodox
+// are infinite as heaven and earth,
+// inexhaustible as the great rivers.
+// When they come to an end,
+// they begin again,
+// like the days and months;
+// they die and are reborn,
+// like the four seasons."
+//
+// - Sun Tsu,
+// "The Art of War"
+
+package com.theartofdev.edmodo.cropper;
+
+import android.app.Activity;
+import android.content.Context;
+import android.content.Intent;
+import android.content.res.TypedArray;
+import android.graphics.Bitmap;
+import android.graphics.BitmapFactory;
+import android.graphics.Matrix;
+import android.graphics.Rect;
+import android.graphics.RectF;
+import android.net.Uri;
+import android.os.AsyncTask;
+import android.os.Bundle;
+import android.os.Parcelable;
+import androidx.exifinterface.media.ExifInterface;
+import android.util.AttributeSet;
+import android.util.Pair;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.FrameLayout;
+import android.widget.ImageView;
+import android.widget.ProgressBar;
+
+import java.lang.ref.WeakReference;
+import java.util.UUID;
+
+/** Custom view that provides cropping capabilities to an image. */
+public class CropImageView extends FrameLayout {
+
+ // region: Fields and Consts
+
+ /** Image view widget used to show the image for cropping. */
+ private final ImageView mImageView;
+
+ /** Overlay over the image view to show cropping UI. */
+ private final CropOverlayView mCropOverlayView;
+
+ /** The matrix used to transform the cropping image in the image view */
+ private final Matrix mImageMatrix = new Matrix();
+
+ /** Reusing matrix instance for reverse matrix calculations. */
+ private final Matrix mImageInverseMatrix = new Matrix();
+
+ /** Progress bar widget to show progress bar on async image loading and cropping. */
+ private final ProgressBar mProgressBar;
+
+ /** Rectangle used in image matrix transformation calculation (reusing rect instance) */
+ private final float[] mImagePoints = new float[8];
+
+ /** Rectangle used in image matrix transformation for scale calculation (reusing rect instance) */
+ private final float[] mScaleImagePoints = new float[8];
+
+ /** Animation class to smooth animate zoom-in/out */
+ private CropImageAnimation mAnimation;
+
+ private Bitmap mBitmap;
+
+ /** The image rotation value used during loading of the image so we can reset to it */
+ private int mInitialDegreesRotated;
+
+ /** How much the image is rotated from original clockwise */
+ private int mDegreesRotated;
+
+ /** if the image flipped horizontally */
+ private boolean mFlipHorizontally;
+
+ /** if the image flipped vertically */
+ private boolean mFlipVertically;
+
+ private int mLayoutWidth;
+
+ private int mLayoutHeight;
+
+ private int mImageResource;
+
+ /** The initial scale type of the image in the crop image view */
+ private ScaleType mScaleType;
+
+ /**
+ * if to save bitmap on save instance state.
+ * It is best to avoid it by using URI in setting image for cropping.
+ * If false the bitmap is not saved and if restore is required to view will be empty, storing the
+ * bitmap requires saving it to file which can be expensive. default: false.
+ */
+ private boolean mSaveBitmapToInstanceState = false;
+
+ /**
+ * if to show crop overlay UI what contains the crop window UI surrounded by background over the
+ * cropping image.
+ * default: true, may disable for animation or frame transition.
+ */
+ private boolean mShowCropOverlay = true;
+
+ /**
+ * if to show progress bar when image async loading/cropping is in progress.
+ * default: true, disable to provide custom progress bar UI.
+ */
+ private boolean mShowProgressBar = true;
+
+ /**
+ * if auto-zoom functionality is enabled.
+ * default: true.
+ */
+ private boolean mAutoZoomEnabled = true;
+
+ /** The max zoom allowed during cropping */
+ private int mMaxZoom;
+
+ /** callback to be invoked when crop overlay is released. */
+ private OnSetCropOverlayReleasedListener mOnCropOverlayReleasedListener;
+
+ /** callback to be invoked when crop overlay is moved. */
+ private OnSetCropOverlayMovedListener mOnSetCropOverlayMovedListener;
+
+ /** callback to be invoked when crop window is changed. */
+ private OnSetCropWindowChangeListener mOnSetCropWindowChangeListener;
+
+ /** callback to be invoked when image async loading is complete. */
+ private OnSetImageUriCompleteListener mOnSetImageUriCompleteListener;
+
+ /** callback to be invoked when image async cropping is complete. */
+ private OnCropImageCompleteListener mOnCropImageCompleteListener;
+
+ /** The URI that the image was loaded from (if loaded from URI) */
+ private Uri mLoadedImageUri;
+
+ /** The sample size the image was loaded by if was loaded by URI */
+ private int mLoadedSampleSize = 1;
+
+ /** The current zoom level to to scale the cropping image */
+ private float mZoom = 1;
+
+ /** The X offset that the cropping image was translated after zooming */
+ private float mZoomOffsetX;
+
+ /** The Y offset that the cropping image was translated after zooming */
+ private float mZoomOffsetY;
+
+ /** Used to restore the cropping windows rectangle after state restore */
+ private RectF mRestoreCropWindowRect;
+
+ /** Used to restore image rotation after state restore */
+ private int mRestoreDegreesRotated;
+
+ /**
+ * Used to detect size change to handle auto-zoom using {@link #handleCropWindowChanged(boolean,
+ * boolean)} in {@link #layout(int, int, int, int)}.
+ */
+ private boolean mSizeChanged;
+
+ /**
+ * Temp URI used to save bitmap image to disk to preserve for instance state in case cropped was
+ * set with bitmap
+ */
+ private Uri mSaveInstanceStateBitmapUri;
+
+ /** Task used to load bitmap async from UI thread */
+ private WeakReference mBitmapLoadingWorkerTask;
+
+ /** Task used to crop bitmap async from UI thread */
+ private WeakReference mBitmapCroppingWorkerTask;
+ // endregion
+
+ public CropImageView(Context context) {
+ this(context, null);
+ }
+
+ public CropImageView(Context context, AttributeSet attrs) {
+ super(context, attrs);
+
+ CropImageOptions options = null;
+ Intent intent = context instanceof Activity ? ((Activity) context).getIntent() : null;
+ if (intent != null) {
+ Bundle bundle = intent.getBundleExtra(CropImage.CROP_IMAGE_EXTRA_BUNDLE);
+ if (bundle != null) {
+ options = bundle.getParcelable(CropImage.CROP_IMAGE_EXTRA_OPTIONS);
+ }
+ }
+
+ if (options == null) {
+
+ options = new CropImageOptions();
+
+ if (attrs != null) {
+ TypedArray ta = context.obtainStyledAttributes(attrs, R.styleable.CropImageView, 0, 0);
+ try {
+ options.fixAspectRatio =
+ ta.getBoolean(R.styleable.CropImageView_cropFixAspectRatio, options.fixAspectRatio);
+ options.aspectRatioX =
+ ta.getInteger(R.styleable.CropImageView_cropAspectRatioX, options.aspectRatioX);
+ options.aspectRatioY =
+ ta.getInteger(R.styleable.CropImageView_cropAspectRatioY, options.aspectRatioY);
+ options.scaleType =
+ ScaleType.values()[
+ ta.getInt(R.styleable.CropImageView_cropScaleType, options.scaleType.ordinal())];
+ options.autoZoomEnabled =
+ ta.getBoolean(R.styleable.CropImageView_cropAutoZoomEnabled, options.autoZoomEnabled);
+ options.multiTouchEnabled =
+ ta.getBoolean(
+ R.styleable.CropImageView_cropMultiTouchEnabled, options.multiTouchEnabled);
+ options.maxZoom = ta.getInteger(R.styleable.CropImageView_cropMaxZoom, options.maxZoom);
+ options.cropShape =
+ CropShape.values()[
+ ta.getInt(R.styleable.CropImageView_cropShape, options.cropShape.ordinal())];
+ options.guidelines =
+ Guidelines.values()[
+ ta.getInt(
+ R.styleable.CropImageView_cropGuidelines, options.guidelines.ordinal())];
+ options.snapRadius =
+ ta.getDimension(R.styleable.CropImageView_cropSnapRadius, options.snapRadius);
+ options.touchRadius =
+ ta.getDimension(R.styleable.CropImageView_cropTouchRadius, options.touchRadius);
+ options.initialCropWindowPaddingRatio =
+ ta.getFloat(
+ R.styleable.CropImageView_cropInitialCropWindowPaddingRatio,
+ options.initialCropWindowPaddingRatio);
+ options.borderLineThickness =
+ ta.getDimension(
+ R.styleable.CropImageView_cropBorderLineThickness, options.borderLineThickness);
+ options.borderLineColor =
+ ta.getInteger(R.styleable.CropImageView_cropBorderLineColor, options.borderLineColor);
+ options.borderCornerThickness =
+ ta.getDimension(
+ R.styleable.CropImageView_cropBorderCornerThickness,
+ options.borderCornerThickness);
+ options.borderCornerOffset =
+ ta.getDimension(
+ R.styleable.CropImageView_cropBorderCornerOffset, options.borderCornerOffset);
+ options.borderCornerLength =
+ ta.getDimension(
+ R.styleable.CropImageView_cropBorderCornerLength, options.borderCornerLength);
+ options.borderCornerColor =
+ ta.getInteger(
+ R.styleable.CropImageView_cropBorderCornerColor, options.borderCornerColor);
+ options.guidelinesThickness =
+ ta.getDimension(
+ R.styleable.CropImageView_cropGuidelinesThickness, options.guidelinesThickness);
+ options.guidelinesColor =
+ ta.getInteger(R.styleable.CropImageView_cropGuidelinesColor, options.guidelinesColor);
+ options.backgroundColor =
+ ta.getInteger(R.styleable.CropImageView_cropBackgroundColor, options.backgroundColor);
+ options.showCropOverlay =
+ ta.getBoolean(R.styleable.CropImageView_cropShowCropOverlay, mShowCropOverlay);
+ options.showProgressBar =
+ ta.getBoolean(R.styleable.CropImageView_cropShowProgressBar, mShowProgressBar);
+ options.borderCornerThickness =
+ ta.getDimension(
+ R.styleable.CropImageView_cropBorderCornerThickness,
+ options.borderCornerThickness);
+ options.minCropWindowWidth =
+ (int)
+ ta.getDimension(
+ R.styleable.CropImageView_cropMinCropWindowWidth, options.minCropWindowWidth);
+ options.minCropWindowHeight =
+ (int)
+ ta.getDimension(
+ R.styleable.CropImageView_cropMinCropWindowHeight,
+ options.minCropWindowHeight);
+ options.minCropResultWidth =
+ (int)
+ ta.getFloat(
+ R.styleable.CropImageView_cropMinCropResultWidthPX,
+ options.minCropResultWidth);
+ options.minCropResultHeight =
+ (int)
+ ta.getFloat(
+ R.styleable.CropImageView_cropMinCropResultHeightPX,
+ options.minCropResultHeight);
+ options.maxCropResultWidth =
+ (int)
+ ta.getFloat(
+ R.styleable.CropImageView_cropMaxCropResultWidthPX,
+ options.maxCropResultWidth);
+ options.maxCropResultHeight =
+ (int)
+ ta.getFloat(
+ R.styleable.CropImageView_cropMaxCropResultHeightPX,
+ options.maxCropResultHeight);
+ options.flipHorizontally =
+ ta.getBoolean(
+ R.styleable.CropImageView_cropFlipHorizontally, options.flipHorizontally);
+ options.flipVertically =
+ ta.getBoolean(R.styleable.CropImageView_cropFlipHorizontally, options.flipVertically);
+
+ mSaveBitmapToInstanceState =
+ ta.getBoolean(
+ R.styleable.CropImageView_cropSaveBitmapToInstanceState,
+ mSaveBitmapToInstanceState);
+
+ // if aspect ratio is set then set fixed to true
+ if (ta.hasValue(R.styleable.CropImageView_cropAspectRatioX)
+ && ta.hasValue(R.styleable.CropImageView_cropAspectRatioX)
+ && !ta.hasValue(R.styleable.CropImageView_cropFixAspectRatio)) {
+ options.fixAspectRatio = true;
+ }
+ } finally {
+ ta.recycle();
+ }
+ }
+ }
+
+ options.validate();
+
+ mScaleType = options.scaleType;
+ mAutoZoomEnabled = options.autoZoomEnabled;
+ mMaxZoom = options.maxZoom;
+ mShowCropOverlay = options.showCropOverlay;
+ mShowProgressBar = options.showProgressBar;
+ mFlipHorizontally = options.flipHorizontally;
+ mFlipVertically = options.flipVertically;
+
+ LayoutInflater inflater = LayoutInflater.from(context);
+ View v = inflater.inflate(R.layout.crop_image_view, this, true);
+
+ mImageView = v.findViewById(R.id.ImageView_image);
+ mImageView.setScaleType(ImageView.ScaleType.MATRIX);
+
+ mCropOverlayView = v.findViewById(R.id.CropOverlayView);
+ mCropOverlayView.setCropWindowChangeListener(
+ new CropOverlayView.CropWindowChangeListener() {
+ @Override
+ public void onCropWindowChanged(boolean inProgress) {
+ handleCropWindowChanged(inProgress, true);
+ OnSetCropOverlayReleasedListener listener = mOnCropOverlayReleasedListener;
+ if (listener != null && !inProgress) {
+ listener.onCropOverlayReleased(getCropRect());
+ }
+ OnSetCropOverlayMovedListener movedListener = mOnSetCropOverlayMovedListener;
+ if (movedListener != null && inProgress) {
+ movedListener.onCropOverlayMoved(getCropRect());
+ }
+ }
+ });
+ mCropOverlayView.setInitialAttributeValues(options);
+
+ mProgressBar = v.findViewById(R.id.CropProgressBar);
+ setProgressBarVisibility();
+ }
+
+ /** Get the scale type of the image in the crop view. */
+ public ScaleType getScaleType() {
+ return mScaleType;
+ }
+
+ /** Set the scale type of the image in the crop view */
+ public void setScaleType(ScaleType scaleType) {
+ if (scaleType != mScaleType) {
+ mScaleType = scaleType;
+ mZoom = 1;
+ mZoomOffsetX = mZoomOffsetY = 0;
+ mCropOverlayView.resetCropOverlayView();
+ requestLayout();
+ }
+ }
+
+ /** The shape of the cropping area - rectangle/circular. */
+ public CropShape getCropShape() {
+ return mCropOverlayView.getCropShape();
+ }
+
+ /**
+ * The shape of the cropping area - rectangle/circular.
+ * To set square/circle crop shape set aspect ratio to 1:1.
+ */
+ public void setCropShape(CropShape cropShape) {
+ mCropOverlayView.setCropShape(cropShape);
+ }
+
+ /** if auto-zoom functionality is enabled. default: true. */
+ public boolean isAutoZoomEnabled() {
+ return mAutoZoomEnabled;
+ }
+
+ /** Set auto-zoom functionality to enabled/disabled. */
+ public void setAutoZoomEnabled(boolean autoZoomEnabled) {
+ if (mAutoZoomEnabled != autoZoomEnabled) {
+ mAutoZoomEnabled = autoZoomEnabled;
+ handleCropWindowChanged(false, false);
+ mCropOverlayView.invalidate();
+ }
+ }
+
+ /** Set multi touch functionality to enabled/disabled. */
+ public void setMultiTouchEnabled(boolean multiTouchEnabled) {
+ if (mCropOverlayView.setMultiTouchEnabled(multiTouchEnabled)) {
+ handleCropWindowChanged(false, false);
+ mCropOverlayView.invalidate();
+ }
+ }
+
+ /** The max zoom allowed during cropping. */
+ public int getMaxZoom() {
+ return mMaxZoom;
+ }
+
+ /** The max zoom allowed during cropping. */
+ public void setMaxZoom(int maxZoom) {
+ if (mMaxZoom != maxZoom && maxZoom > 0) {
+ mMaxZoom = maxZoom;
+ handleCropWindowChanged(false, false);
+ mCropOverlayView.invalidate();
+ }
+ }
+
+ /**
+ * the min size the resulting cropping image is allowed to be, affects the cropping window limits
+ * (in pixels).
+ */
+ public void setMinCropResultSize(int minCropResultWidth, int minCropResultHeight) {
+ mCropOverlayView.setMinCropResultSize(minCropResultWidth, minCropResultHeight);
+ }
+
+ /**
+ * the max size the resulting cropping image is allowed to be, affects the cropping window limits
+ * (in pixels).
+ */
+ public void setMaxCropResultSize(int maxCropResultWidth, int maxCropResultHeight) {
+ mCropOverlayView.setMaxCropResultSize(maxCropResultWidth, maxCropResultHeight);
+ }
+
+ /**
+ * Get the amount of degrees the cropping image is rotated cloackwise.
+ *
+ * @return 0-360
+ */
+ public int getRotatedDegrees() {
+ return mDegreesRotated;
+ }
+
+ /**
+ * Set the amount of degrees the cropping image is rotated cloackwise.
+ *
+ * @param degrees 0-360
+ */
+ public void setRotatedDegrees(int degrees) {
+ if (mDegreesRotated != degrees) {
+ rotateImage(degrees - mDegreesRotated);
+ }
+ }
+
+ /**
+ * whether the aspect ratio is fixed or not; true fixes the aspect ratio, while false allows it to
+ * be changed.
+ */
+ public boolean isFixAspectRatio() {
+ return mCropOverlayView.isFixAspectRatio();
+ }
+
+ /**
+ * Sets whether the aspect ratio is fixed or not; true fixes the aspect ratio, while false allows
+ * it to be changed.
+ */
+ public void setFixedAspectRatio(boolean fixAspectRatio) {
+ mCropOverlayView.setFixedAspectRatio(fixAspectRatio);
+ }
+
+ /** whether the image should be flipped horizontally */
+ public boolean isFlippedHorizontally() {
+ return mFlipHorizontally;
+ }
+
+ /** Sets whether the image should be flipped horizontally */
+ public void setFlippedHorizontally(boolean flipHorizontally) {
+ if (mFlipHorizontally != flipHorizontally) {
+ mFlipHorizontally = flipHorizontally;
+ applyImageMatrix(getWidth(), getHeight(), true, false);
+ }
+ }
+
+ /** whether the image should be flipped vertically */
+ public boolean isFlippedVertically() {
+ return mFlipVertically;
+ }
+
+ /** Sets whether the image should be flipped vertically */
+ public void setFlippedVertically(boolean flipVertically) {
+ if (mFlipVertically != flipVertically) {
+ mFlipVertically = flipVertically;
+ applyImageMatrix(getWidth(), getHeight(), true, false);
+ }
+ }
+
+ /** Get the current guidelines option set. */
+ public Guidelines getGuidelines() {
+ return mCropOverlayView.getGuidelines();
+ }
+
+ /**
+ * Sets the guidelines for the CropOverlayView to be either on, off, or to show when resizing the
+ * application.
+ */
+ public void setGuidelines(Guidelines guidelines) {
+ mCropOverlayView.setGuidelines(guidelines);
+ }
+
+ /** both the X and Y values of the aspectRatio. */
+ public Pair getAspectRatio() {
+ return new Pair<>(mCropOverlayView.getAspectRatioX(), mCropOverlayView.getAspectRatioY());
+ }
+
+ /**
+ * Sets the both the X and Y values of the aspectRatio.
+ * Sets fixed aspect ratio to TRUE.
+ *
+ * @param aspectRatioX int that specifies the new X value of the aspect ratio
+ * @param aspectRatioY int that specifies the new Y value of the aspect ratio
+ */
+ public void setAspectRatio(int aspectRatioX, int aspectRatioY) {
+ mCropOverlayView.setAspectRatioX(aspectRatioX);
+ mCropOverlayView.setAspectRatioY(aspectRatioY);
+ setFixedAspectRatio(true);
+ }
+
+ /** Clears set aspect ratio values and sets fixed aspect ratio to FALSE. */
+ public void clearAspectRatio() {
+ mCropOverlayView.setAspectRatioX(1);
+ mCropOverlayView.setAspectRatioY(1);
+ setFixedAspectRatio(false);
+ }
+
+ /**
+ * An edge of the crop window will snap to the corresponding edge of a specified bounding box when
+ * the crop window edge is less than or equal to this distance (in pixels) away from the bounding
+ * box edge. (default: 3dp)
+ */
+ public void setSnapRadius(float snapRadius) {
+ if (snapRadius >= 0) {
+ mCropOverlayView.setSnapRadius(snapRadius);
+ }
+ }
+
+ /**
+ * if to show progress bar when image async loading/cropping is in progress.
+ * default: true, disable to provide custom progress bar UI.
+ */
+ public boolean isShowProgressBar() {
+ return mShowProgressBar;
+ }
+
+ /**
+ * if to show progress bar when image async loading/cropping is in progress.
+ * default: true, disable to provide custom progress bar UI.
+ */
+ public void setShowProgressBar(boolean showProgressBar) {
+ if (mShowProgressBar != showProgressBar) {
+ mShowProgressBar = showProgressBar;
+ setProgressBarVisibility();
+ }
+ }
+
+ /**
+ * if to show crop overlay UI what contains the crop window UI surrounded by background over the
+ * cropping image.
+ * default: true, may disable for animation or frame transition.
+ */
+ public boolean isShowCropOverlay() {
+ return mShowCropOverlay;
+ }
+
+ /**
+ * if to show crop overlay UI what contains the crop window UI surrounded by background over the
+ * cropping image.
+ * default: true, may disable for animation or frame transition.
+ */
+ public void setShowCropOverlay(boolean showCropOverlay) {
+ if (mShowCropOverlay != showCropOverlay) {
+ mShowCropOverlay = showCropOverlay;
+ setCropOverlayVisibility();
+ }
+ }
+
+ /**
+ * if to save bitmap on save instance state.
+ * It is best to avoid it by using URI in setting image for cropping.
+ * If false the bitmap is not saved and if restore is required to view will be empty, storing the
+ * bitmap requires saving it to file which can be expensive. default: false.
+ */
+ public boolean isSaveBitmapToInstanceState() {
+ return mSaveBitmapToInstanceState;
+ }
+
+ /**
+ * if to save bitmap on save instance state.
+ * It is best to avoid it by using URI in setting image for cropping.
+ * If false the bitmap is not saved and if restore is required to view will be empty, storing the
+ * bitmap requires saving it to file which can be expensive. default: false.
+ */
+ public void setSaveBitmapToInstanceState(boolean saveBitmapToInstanceState) {
+ mSaveBitmapToInstanceState = saveBitmapToInstanceState;
+ }
+
+ /** Returns the integer of the imageResource */
+ public int getImageResource() {
+ return mImageResource;
+ }
+
+ /** Get the URI of an image that was set by URI, null otherwise. */
+ public Uri getImageUri() {
+ return mLoadedImageUri;
+ }
+
+ /**
+ * Gets the source Bitmap's dimensions. This represents the largest possible crop rectangle.
+ *
+ * @return a Rect instance dimensions of the source Bitmap
+ */
+ public Rect getWholeImageRect() {
+ int loadedSampleSize = mLoadedSampleSize;
+ Bitmap bitmap = mBitmap;
+ if (bitmap == null) {
+ return null;
+ }
+
+ int orgWidth = bitmap.getWidth() * loadedSampleSize;
+ int orgHeight = bitmap.getHeight() * loadedSampleSize;
+ return new Rect(0, 0, orgWidth, orgHeight);
+ }
+
+ /**
+ * Gets the crop window's position relative to the source Bitmap (not the image displayed in the
+ * CropImageView) using the original image rotation.
+ *
+ * @return a Rect instance containing cropped area boundaries of the source Bitmap
+ */
+ public Rect getCropRect() {
+ int loadedSampleSize = mLoadedSampleSize;
+ Bitmap bitmap = mBitmap;
+ if (bitmap == null) {
+ return null;
+ }
+
+ // get the points of the crop rectangle adjusted to source bitmap
+ float[] points = getCropPoints();
+
+ int orgWidth = bitmap.getWidth() * loadedSampleSize;
+ int orgHeight = bitmap.getHeight() * loadedSampleSize;
+
+ // get the rectangle for the points (it may be larger than original if rotation is not stright)
+ return BitmapUtils.getRectFromPoints(
+ points,
+ orgWidth,
+ orgHeight,
+ mCropOverlayView.isFixAspectRatio(),
+ mCropOverlayView.getAspectRatioX(),
+ mCropOverlayView.getAspectRatioY());
+ }
+
+ /**
+ * Gets the crop window's position relative to the parent's view at screen.
+ *
+ * @return a Rect instance containing cropped area boundaries of the source Bitmap
+ */
+ public RectF getCropWindowRect() {
+ if (mCropOverlayView == null) {
+ return null;
+ }
+ return mCropOverlayView.getCropWindowRect();
+ }
+
+ /**
+ * Gets the 4 points of crop window's position relative to the source Bitmap (not the image
+ * displayed in the CropImageView) using the original image rotation.
+ * Note: the 4 points may not be a rectangle if the image was rotates to NOT stright angle (!=
+ * 90/180/270).
+ *
+ * @return 4 points (x0,y0,x1,y1,x2,y2,x3,y3) of cropped area boundaries
+ */
+ public float[] getCropPoints() {
+
+ // Get crop window position relative to the displayed image.
+ RectF cropWindowRect = mCropOverlayView.getCropWindowRect();
+
+ float[] points =
+ new float[] {
+ cropWindowRect.left,
+ cropWindowRect.top,
+ cropWindowRect.right,
+ cropWindowRect.top,
+ cropWindowRect.right,
+ cropWindowRect.bottom,
+ cropWindowRect.left,
+ cropWindowRect.bottom
+ };
+
+ mImageMatrix.invert(mImageInverseMatrix);
+ mImageInverseMatrix.mapPoints(points);
+
+ for (int i = 0; i < points.length; i++) {
+ points[i] *= mLoadedSampleSize;
+ }
+
+ return points;
+ }
+
+ /**
+ * Set the crop window position and size to the given rectangle.
+ * Image to crop must be first set before invoking this, for async - after complete callback.
+ *
+ * @param rect window rectangle (position and size) relative to source bitmap
+ */
+ public void setCropRect(Rect rect) {
+ mCropOverlayView.setInitialCropWindowRect(rect);
+ }
+
+ /** Reset crop window to initial rectangle. */
+ public void resetCropRect() {
+ mZoom = 1;
+ mZoomOffsetX = 0;
+ mZoomOffsetY = 0;
+ mDegreesRotated = mInitialDegreesRotated;
+ mFlipHorizontally = false;
+ mFlipVertically = false;
+ applyImageMatrix(getWidth(), getHeight(), false, false);
+ mCropOverlayView.resetCropWindowRect();
+ }
+
+ /**
+ * Gets the cropped image based on the current crop window.
+ *
+ * @return a new Bitmap representing the cropped image
+ */
+ public Bitmap getCroppedImage() {
+ return getCroppedImage(0, 0, RequestSizeOptions.NONE);
+ }
+
+ /**
+ * Gets the cropped image based on the current crop window.
+ * Uses {@link RequestSizeOptions#RESIZE_INSIDE} option.
+ *
+ * @param reqWidth the width to resize the cropped image to
+ * @param reqHeight the height to resize the cropped image to
+ * @return a new Bitmap representing the cropped image
+ */
+ public Bitmap getCroppedImage(int reqWidth, int reqHeight) {
+ return getCroppedImage(reqWidth, reqHeight, RequestSizeOptions.RESIZE_INSIDE);
+ }
+
+ /**
+ * Gets the cropped image based on the current crop window.
+ *
+ * @param reqWidth the width to resize the cropped image to (see options)
+ * @param reqHeight the height to resize the cropped image to (see options)
+ * @param options the resize method to use, see its documentation
+ * @return a new Bitmap representing the cropped image
+ */
+ public Bitmap getCroppedImage(int reqWidth, int reqHeight, RequestSizeOptions options) {
+ Bitmap croppedBitmap = null;
+ if (mBitmap != null) {
+ mImageView.clearAnimation();
+
+ reqWidth = options != RequestSizeOptions.NONE ? reqWidth : 0;
+ reqHeight = options != RequestSizeOptions.NONE ? reqHeight : 0;
+
+ if (mLoadedImageUri != null
+ && (mLoadedSampleSize > 1 || options == RequestSizeOptions.SAMPLING)) {
+ int orgWidth = mBitmap.getWidth() * mLoadedSampleSize;
+ int orgHeight = mBitmap.getHeight() * mLoadedSampleSize;
+ BitmapUtils.BitmapSampled bitmapSampled =
+ BitmapUtils.cropBitmap(
+ getContext(),
+ mLoadedImageUri,
+ getCropPoints(),
+ mDegreesRotated,
+ orgWidth,
+ orgHeight,
+ mCropOverlayView.isFixAspectRatio(),
+ mCropOverlayView.getAspectRatioX(),
+ mCropOverlayView.getAspectRatioY(),
+ reqWidth,
+ reqHeight,
+ mFlipHorizontally,
+ mFlipVertically);
+ croppedBitmap = bitmapSampled.bitmap;
+ } else {
+ croppedBitmap =
+ BitmapUtils.cropBitmapObjectHandleOOM(
+ mBitmap,
+ getCropPoints(),
+ mDegreesRotated,
+ mCropOverlayView.isFixAspectRatio(),
+ mCropOverlayView.getAspectRatioX(),
+ mCropOverlayView.getAspectRatioY(),
+ mFlipHorizontally,
+ mFlipVertically)
+ .bitmap;
+ }
+
+ croppedBitmap = BitmapUtils.resizeBitmap(croppedBitmap, reqWidth, reqHeight, options);
+ }
+
+ return croppedBitmap;
+ }
+
+ /**
+ * Gets the cropped image based on the current crop window.
+ * The result will be invoked to listener set by {@link
+ * #setOnCropImageCompleteListener(OnCropImageCompleteListener)}.
+ */
+ public void getCroppedImageAsync() {
+ getCroppedImageAsync(0, 0, RequestSizeOptions.NONE);
+ }
+
+ /**
+ * Gets the cropped image based on the current crop window.
+ * Uses {@link RequestSizeOptions#RESIZE_INSIDE} option.
+ * The result will be invoked to listener set by {@link
+ * #setOnCropImageCompleteListener(OnCropImageCompleteListener)}.
+ *
+ * @param reqWidth the width to resize the cropped image to
+ * @param reqHeight the height to resize the cropped image to
+ */
+ public void getCroppedImageAsync(int reqWidth, int reqHeight) {
+ getCroppedImageAsync(reqWidth, reqHeight, RequestSizeOptions.RESIZE_INSIDE);
+ }
+
+ /**
+ * Gets the cropped image based on the current crop window.
+ * The result will be invoked to listener set by {@link
+ * #setOnCropImageCompleteListener(OnCropImageCompleteListener)}.
+ *
+ * @param reqWidth the width to resize the cropped image to (see options)
+ * @param reqHeight the height to resize the cropped image to (see options)
+ * @param options the resize method to use, see its documentation
+ */
+ public void getCroppedImageAsync(int reqWidth, int reqHeight, RequestSizeOptions options) {
+ if (mOnCropImageCompleteListener == null) {
+ throw new IllegalArgumentException("mOnCropImageCompleteListener is not set");
+ }
+ startCropWorkerTask(reqWidth, reqHeight, options, null, null, 0);
+ }
+
+ /**
+ * Save the cropped image based on the current crop window to the given uri.
+ * Uses JPEG image compression with 90 compression quality.
+ * The result will be invoked to listener set by {@link
+ * #setOnCropImageCompleteListener(OnCropImageCompleteListener)}.
+ *
+ * @param saveUri the Android Uri to save the cropped image to
+ */
+ public void saveCroppedImageAsync(Uri saveUri) {
+ saveCroppedImageAsync(saveUri, Bitmap.CompressFormat.JPEG, 90, 0, 0, RequestSizeOptions.NONE);
+ }
+
+ /**
+ * Save the cropped image based on the current crop window to the given uri.
+ * The result will be invoked to listener set by {@link
+ * #setOnCropImageCompleteListener(OnCropImageCompleteListener)}.
+ *
+ * @param saveUri the Android Uri to save the cropped image to
+ * @param saveCompressFormat the compression format to use when writing the image
+ * @param saveCompressQuality the quality (if applicable) to use when writing the image (0 - 100)
+ */
+ public void saveCroppedImageAsync(
+ Uri saveUri, Bitmap.CompressFormat saveCompressFormat, int saveCompressQuality) {
+ saveCroppedImageAsync(
+ saveUri, saveCompressFormat, saveCompressQuality, 0, 0, RequestSizeOptions.NONE);
+ }
+
+ /**
+ * Save the cropped image based on the current crop window to the given uri.
+ * Uses {@link RequestSizeOptions#RESIZE_INSIDE} option.
+ * The result will be invoked to listener set by {@link
+ * #setOnCropImageCompleteListener(OnCropImageCompleteListener)}.
+ *
+ * @param saveUri the Android Uri to save the cropped image to
+ * @param saveCompressFormat the compression format to use when writing the image
+ * @param saveCompressQuality the quality (if applicable) to use when writing the image (0 - 100)
+ * @param reqWidth the width to resize the cropped image to
+ * @param reqHeight the height to resize the cropped image to
+ */
+ public void saveCroppedImageAsync(
+ Uri saveUri,
+ Bitmap.CompressFormat saveCompressFormat,
+ int saveCompressQuality,
+ int reqWidth,
+ int reqHeight) {
+ saveCroppedImageAsync(
+ saveUri,
+ saveCompressFormat,
+ saveCompressQuality,
+ reqWidth,
+ reqHeight,
+ RequestSizeOptions.RESIZE_INSIDE);
+ }
+
+ /**
+ * Save the cropped image based on the current crop window to the given uri.
+ * The result will be invoked to listener set by {@link
+ * #setOnCropImageCompleteListener(OnCropImageCompleteListener)}.
+ *
+ * @param saveUri the Android Uri to save the cropped image to
+ * @param saveCompressFormat the compression format to use when writing the image
+ * @param saveCompressQuality the quality (if applicable) to use when writing the image (0 - 100)
+ * @param reqWidth the width to resize the cropped image to (see options)
+ * @param reqHeight the height to resize the cropped image to (see options)
+ * @param options the resize method to use, see its documentation
+ */
+ public void saveCroppedImageAsync(
+ Uri saveUri,
+ Bitmap.CompressFormat saveCompressFormat,
+ int saveCompressQuality,
+ int reqWidth,
+ int reqHeight,
+ RequestSizeOptions options) {
+ if (mOnCropImageCompleteListener == null) {
+ throw new IllegalArgumentException("mOnCropImageCompleteListener is not set");
+ }
+ startCropWorkerTask(
+ reqWidth, reqHeight, options, saveUri, saveCompressFormat, saveCompressQuality);
+ }
+
+ /** Set the callback t */
+ public void setOnSetCropOverlayReleasedListener(OnSetCropOverlayReleasedListener listener) {
+ mOnCropOverlayReleasedListener = listener;
+ }
+
+ /** Set the callback when the cropping is moved */
+ public void setOnSetCropOverlayMovedListener(OnSetCropOverlayMovedListener listener) {
+ mOnSetCropOverlayMovedListener = listener;
+ }
+
+ /** Set the callback when the crop window is changed */
+ public void setOnCropWindowChangedListener(OnSetCropWindowChangeListener listener) {
+ mOnSetCropWindowChangeListener = listener;
+ }
+
+ /**
+ * Set the callback to be invoked when image async loading ({@link #setImageUriAsync(Uri)}) is
+ * complete (successful or failed).
+ */
+ public void setOnSetImageUriCompleteListener(OnSetImageUriCompleteListener listener) {
+ mOnSetImageUriCompleteListener = listener;
+ }
+
+ /**
+ * Set the callback to be invoked when image async cropping image ({@link #getCroppedImageAsync()}
+ * or {@link #saveCroppedImageAsync(Uri)}) is complete (successful or failed).
+ */
+ public void setOnCropImageCompleteListener(OnCropImageCompleteListener listener) {
+ mOnCropImageCompleteListener = listener;
+ }
+
+ /**
+ * Sets a Bitmap as the content of the CropImageView.
+ *
+ * @param bitmap the Bitmap to set
+ */
+ public void setImageBitmap(Bitmap bitmap) {
+ mCropOverlayView.setInitialCropWindowRect(null);
+ setBitmap(bitmap, 0, null, 1, 0);
+ }
+
+ /**
+ * Sets a Bitmap and initializes the image rotation according to the EXIT data.
+ *
+ * The EXIF can be retrieved by doing the following:
+ * ExifInterface exif = new ExifInterface(path);
+ *
+ * @param bitmap the original bitmap to set; if null, this
+ * @param exif the EXIF information about this bitmap; may be null
+ */
+ public void setImageBitmap(Bitmap bitmap, ExifInterface exif) {
+ Bitmap setBitmap;
+ int degreesRotated = 0;
+ if (bitmap != null && exif != null) {
+ BitmapUtils.RotateBitmapResult result = BitmapUtils.rotateBitmapByExif(bitmap, exif);
+ setBitmap = result.bitmap;
+ degreesRotated = result.degrees;
+ mInitialDegreesRotated = result.degrees;
+ } else {
+ setBitmap = bitmap;
+ }
+ mCropOverlayView.setInitialCropWindowRect(null);
+ setBitmap(setBitmap, 0, null, 1, degreesRotated);
+ }
+
+ /**
+ * Sets a Drawable as the content of the CropImageView.
+ *
+ * @param resId the drawable resource ID to set
+ */
+ public void setImageResource(int resId) {
+ if (resId != 0) {
+ mCropOverlayView.setInitialCropWindowRect(null);
+ Bitmap bitmap = BitmapFactory.decodeResource(getResources(), resId);
+ setBitmap(bitmap, resId, null, 1, 0);
+ }
+ }
+
+ /**
+ * Sets a bitmap loaded from the given Android URI as the content of the CropImageView.
+ * Can be used with URI from gallery or camera source.
+ * Will rotate the image by exif data.
+ *
+ * @param uri the URI to load the image from
+ */
+ public void setImageUriAsync(Uri uri) {
+ if (uri != null) {
+ BitmapLoadingWorkerTask currentTask =
+ mBitmapLoadingWorkerTask != null ? mBitmapLoadingWorkerTask.get() : null;
+ if (currentTask != null) {
+ // cancel previous loading (no check if the same URI because camera URI can be the same for
+ // different images)
+ currentTask.cancel(true);
+ }
+
+ // either no existing task is working or we canceled it, need to load new URI
+ clearImageInt();
+ mRestoreCropWindowRect = null;
+ mRestoreDegreesRotated = 0;
+ mCropOverlayView.setInitialCropWindowRect(null);
+ mBitmapLoadingWorkerTask = new WeakReference<>(new BitmapLoadingWorkerTask(this, uri));
+ mBitmapLoadingWorkerTask.get().executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR);
+ setProgressBarVisibility();
+ }
+ }
+
+ /** Clear the current image set for cropping. */
+ public void clearImage() {
+ clearImageInt();
+ mCropOverlayView.setInitialCropWindowRect(null);
+ }
+
+ /**
+ * Rotates image by the specified number of degrees clockwise.
+ * Negative values represent counter-clockwise rotations.
+ *
+ * @param degrees Integer specifying the number of degrees to rotate.
+ */
+ public void rotateImage(int degrees) {
+ if (mBitmap != null) {
+ // Force degrees to be a non-zero value between 0 and 360 (inclusive)
+ if (degrees < 0) {
+ degrees = (degrees % 360) + 360;
+ } else {
+ degrees = degrees % 360;
+ }
+
+ boolean flipAxes =
+ !mCropOverlayView.isFixAspectRatio()
+ && ((degrees > 45 && degrees < 135) || (degrees > 215 && degrees < 305));
+ BitmapUtils.RECT.set(mCropOverlayView.getCropWindowRect());
+ float halfWidth = (flipAxes ? BitmapUtils.RECT.height() : BitmapUtils.RECT.width()) / 2f;
+ float halfHeight = (flipAxes ? BitmapUtils.RECT.width() : BitmapUtils.RECT.height()) / 2f;
+ if (flipAxes) {
+ boolean isFlippedHorizontally = mFlipHorizontally;
+ mFlipHorizontally = mFlipVertically;
+ mFlipVertically = isFlippedHorizontally;
+ }
+
+ mImageMatrix.invert(mImageInverseMatrix);
+
+ BitmapUtils.POINTS[0] = BitmapUtils.RECT.centerX();
+ BitmapUtils.POINTS[1] = BitmapUtils.RECT.centerY();
+ BitmapUtils.POINTS[2] = 0;
+ BitmapUtils.POINTS[3] = 0;
+ BitmapUtils.POINTS[4] = 1;
+ BitmapUtils.POINTS[5] = 0;
+ mImageInverseMatrix.mapPoints(BitmapUtils.POINTS);
+
+ // This is valid because degrees is not negative.
+ mDegreesRotated = (mDegreesRotated + degrees) % 360;
+
+ applyImageMatrix(getWidth(), getHeight(), true, false);
+
+ // adjust the zoom so the crop window size remains the same even after image scale change
+ mImageMatrix.mapPoints(BitmapUtils.POINTS2, BitmapUtils.POINTS);
+ mZoom /=
+ Math.sqrt(
+ Math.pow(BitmapUtils.POINTS2[4] - BitmapUtils.POINTS2[2], 2)
+ + Math.pow(BitmapUtils.POINTS2[5] - BitmapUtils.POINTS2[3], 2));
+ mZoom = Math.max(mZoom, 1);
+
+ applyImageMatrix(getWidth(), getHeight(), true, false);
+
+ mImageMatrix.mapPoints(BitmapUtils.POINTS2, BitmapUtils.POINTS);
+
+ // adjust the width/height by the changes in scaling to the image
+ double change =
+ Math.sqrt(
+ Math.pow(BitmapUtils.POINTS2[4] - BitmapUtils.POINTS2[2], 2)
+ + Math.pow(BitmapUtils.POINTS2[5] - BitmapUtils.POINTS2[3], 2));
+ halfWidth *= change;
+ halfHeight *= change;
+
+ // calculate the new crop window rectangle to center in the same location and have proper
+ // width/height
+ BitmapUtils.RECT.set(
+ BitmapUtils.POINTS2[0] - halfWidth,
+ BitmapUtils.POINTS2[1] - halfHeight,
+ BitmapUtils.POINTS2[0] + halfWidth,
+ BitmapUtils.POINTS2[1] + halfHeight);
+
+ mCropOverlayView.resetCropOverlayView();
+ mCropOverlayView.setCropWindowRect(BitmapUtils.RECT);
+ applyImageMatrix(getWidth(), getHeight(), true, false);
+ handleCropWindowChanged(false, false);
+
+ // make sure the crop window rectangle is within the cropping image bounds after all the
+ // changes
+ mCropOverlayView.fixCurrentCropWindowRect();
+ }
+ }
+
+ /** Flips the image horizontally. */
+ public void flipImageHorizontally() {
+ mFlipHorizontally = !mFlipHorizontally;
+ applyImageMatrix(getWidth(), getHeight(), true, false);
+ }
+
+ /** Flips the image vertically. */
+ public void flipImageVertically() {
+ mFlipVertically = !mFlipVertically;
+ applyImageMatrix(getWidth(), getHeight(), true, false);
+ }
+
+ // region: Private methods
+
+ /**
+ * On complete of the async bitmap loading by {@link #setImageUriAsync(Uri)} set the result to the
+ * widget if still relevant and call listener if set.
+ *
+ * @param result the result of bitmap loading
+ */
+ void onSetImageUriAsyncComplete(BitmapLoadingWorkerTask.Result result) {
+
+ mBitmapLoadingWorkerTask = null;
+ setProgressBarVisibility();
+
+ if (result.error == null) {
+ mInitialDegreesRotated = result.degreesRotated;
+ setBitmap(result.bitmap, 0, result.uri, result.loadSampleSize, result.degreesRotated);
+ }
+
+ OnSetImageUriCompleteListener listener = mOnSetImageUriCompleteListener;
+ if (listener != null) {
+ listener.onSetImageUriComplete(this, result.uri, result.error);
+ }
+ }
+
+ /**
+ * On complete of the async bitmap cropping by {@link #getCroppedImageAsync()} call listener if
+ * set.
+ *
+ * @param result the result of bitmap cropping
+ */
+ void onImageCroppingAsyncComplete(BitmapCroppingWorkerTask.Result result) {
+
+ mBitmapCroppingWorkerTask = null;
+ setProgressBarVisibility();
+
+ OnCropImageCompleteListener listener = mOnCropImageCompleteListener;
+ if (listener != null) {
+ CropResult cropResult =
+ new CropResult(
+ mBitmap,
+ mLoadedImageUri,
+ result.bitmap,
+ result.uri,
+ result.error,
+ getCropPoints(),
+ getCropRect(),
+ getWholeImageRect(),
+ getRotatedDegrees(),
+ result.sampleSize);
+ listener.onCropImageComplete(this, cropResult);
+ }
+ }
+
+ /**
+ * Set the given bitmap to be used in for cropping
+ * Optionally clear full if the bitmap is new, or partial clear if the bitmap has been
+ * manipulated.
+ */
+ private void setBitmap(
+ Bitmap bitmap, int imageResource, Uri imageUri, int loadSampleSize, int degreesRotated) {
+ if (mBitmap == null || !mBitmap.equals(bitmap)) {
+
+ mImageView.clearAnimation();
+
+ clearImageInt();
+
+ mBitmap = bitmap;
+ mImageView.setImageBitmap(mBitmap);
+
+ mLoadedImageUri = imageUri;
+ mImageResource = imageResource;
+ mLoadedSampleSize = loadSampleSize;
+ mDegreesRotated = degreesRotated;
+
+ applyImageMatrix(getWidth(), getHeight(), true, false);
+
+ if (mCropOverlayView != null) {
+ mCropOverlayView.resetCropOverlayView();
+ setCropOverlayVisibility();
+ }
+ }
+ }
+
+ /**
+ * Clear the current image set for cropping.
+ * Full clear will also clear the data of the set image like Uri or Resource id while partial
+ * clear will only clear the bitmap and recycle if required.
+ */
+ private void clearImageInt() {
+
+ // if we allocated the bitmap, release it as fast as possible
+ if (mBitmap != null && (mImageResource > 0 || mLoadedImageUri != null)) {
+ mBitmap.recycle();
+ }
+ mBitmap = null;
+
+ // clean the loaded image flags for new image
+ mImageResource = 0;
+ mLoadedImageUri = null;
+ mLoadedSampleSize = 1;
+ mDegreesRotated = 0;
+ mZoom = 1;
+ mZoomOffsetX = 0;
+ mZoomOffsetY = 0;
+ mImageMatrix.reset();
+ mSaveInstanceStateBitmapUri = null;
+
+ mImageView.setImageBitmap(null);
+
+ setCropOverlayVisibility();
+ }
+
+ /**
+ * Gets the cropped image based on the current crop window.
+ * If (reqWidth,reqHeight) is given AND image is loaded from URI cropping will try to use sample
+ * size to fit in the requested width and height down-sampling if possible - optimization to get
+ * best size to quality.
+ * The result will be invoked to listener set by {@link
+ * #setOnCropImageCompleteListener(OnCropImageCompleteListener)}.
+ *
+ * @param reqWidth the width to resize the cropped image to (see options)
+ * @param reqHeight the height to resize the cropped image to (see options)
+ * @param options the resize method to use on the cropped bitmap
+ * @param saveUri optional: to save the cropped image to
+ * @param saveCompressFormat if saveUri is given, the given compression will be used for saving
+ * the image
+ * @param saveCompressQuality if saveUri is given, the given quality will be used for the
+ * compression.
+ */
+ public void startCropWorkerTask(
+ int reqWidth,
+ int reqHeight,
+ RequestSizeOptions options,
+ Uri saveUri,
+ Bitmap.CompressFormat saveCompressFormat,
+ int saveCompressQuality) {
+ Bitmap bitmap = mBitmap;
+ if (bitmap != null) {
+ mImageView.clearAnimation();
+
+ BitmapCroppingWorkerTask currentTask =
+ mBitmapCroppingWorkerTask != null ? mBitmapCroppingWorkerTask.get() : null;
+ if (currentTask != null) {
+ // cancel previous cropping
+ currentTask.cancel(true);
+ }
+
+ reqWidth = options != RequestSizeOptions.NONE ? reqWidth : 0;
+ reqHeight = options != RequestSizeOptions.NONE ? reqHeight : 0;
+
+ int orgWidth = bitmap.getWidth() * mLoadedSampleSize;
+ int orgHeight = bitmap.getHeight() * mLoadedSampleSize;
+ if (mLoadedImageUri != null
+ && (mLoadedSampleSize > 1 || options == RequestSizeOptions.SAMPLING)) {
+ mBitmapCroppingWorkerTask =
+ new WeakReference<>(
+ new BitmapCroppingWorkerTask(
+ this,
+ mLoadedImageUri,
+ getCropPoints(),
+ mDegreesRotated,
+ orgWidth,
+ orgHeight,
+ mCropOverlayView.isFixAspectRatio(),
+ mCropOverlayView.getAspectRatioX(),
+ mCropOverlayView.getAspectRatioY(),
+ reqWidth,
+ reqHeight,
+ mFlipHorizontally,
+ mFlipVertically,
+ options,
+ saveUri,
+ saveCompressFormat,
+ saveCompressQuality));
+ } else {
+ mBitmapCroppingWorkerTask =
+ new WeakReference<>(
+ new BitmapCroppingWorkerTask(
+ this,
+ bitmap,
+ getCropPoints(),
+ mDegreesRotated,
+ mCropOverlayView.isFixAspectRatio(),
+ mCropOverlayView.getAspectRatioX(),
+ mCropOverlayView.getAspectRatioY(),
+ reqWidth,
+ reqHeight,
+ mFlipHorizontally,
+ mFlipVertically,
+ options,
+ saveUri,
+ saveCompressFormat,
+ saveCompressQuality));
+ }
+ mBitmapCroppingWorkerTask.get().executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR);
+ setProgressBarVisibility();
+ }
+ }
+
+ @Override
+ public Parcelable onSaveInstanceState() {
+ if (mLoadedImageUri == null && mBitmap == null && mImageResource < 1) {
+ return super.onSaveInstanceState();
+ }
+
+ Bundle bundle = new Bundle();
+ Uri imageUri = mLoadedImageUri;
+ if (mSaveBitmapToInstanceState && imageUri == null && mImageResource < 1) {
+ mSaveInstanceStateBitmapUri =
+ imageUri =
+ BitmapUtils.writeTempStateStoreBitmap(
+ getContext(), mBitmap, mSaveInstanceStateBitmapUri);
+ }
+ if (imageUri != null && mBitmap != null) {
+ String key = UUID.randomUUID().toString();
+ BitmapUtils.mStateBitmap = new Pair<>(key, new WeakReference<>(mBitmap));
+ bundle.putString("LOADED_IMAGE_STATE_BITMAP_KEY", key);
+ }
+ if (mBitmapLoadingWorkerTask != null) {
+ BitmapLoadingWorkerTask task = mBitmapLoadingWorkerTask.get();
+ if (task != null) {
+ bundle.putParcelable("LOADING_IMAGE_URI", task.getUri());
+ }
+ }
+ bundle.putParcelable("instanceState", super.onSaveInstanceState());
+ bundle.putParcelable("LOADED_IMAGE_URI", imageUri);
+ bundle.putInt("LOADED_IMAGE_RESOURCE", mImageResource);
+ bundle.putInt("LOADED_SAMPLE_SIZE", mLoadedSampleSize);
+ bundle.putInt("DEGREES_ROTATED", mDegreesRotated);
+ bundle.putParcelable("INITIAL_CROP_RECT", mCropOverlayView.getInitialCropWindowRect());
+
+ BitmapUtils.RECT.set(mCropOverlayView.getCropWindowRect());
+
+ mImageMatrix.invert(mImageInverseMatrix);
+ mImageInverseMatrix.mapRect(BitmapUtils.RECT);
+
+ bundle.putParcelable("CROP_WINDOW_RECT", BitmapUtils.RECT);
+ bundle.putString("CROP_SHAPE", mCropOverlayView.getCropShape().name());
+ bundle.putBoolean("CROP_AUTO_ZOOM_ENABLED", mAutoZoomEnabled);
+ bundle.putInt("CROP_MAX_ZOOM", mMaxZoom);
+ bundle.putBoolean("CROP_FLIP_HORIZONTALLY", mFlipHorizontally);
+ bundle.putBoolean("CROP_FLIP_VERTICALLY", mFlipVertically);
+
+ return bundle;
+ }
+
+ @Override
+ public void onRestoreInstanceState(Parcelable state) {
+
+ if (state instanceof Bundle) {
+ Bundle bundle = (Bundle) state;
+
+ // prevent restoring state if already set by outside code
+ if (mBitmapLoadingWorkerTask == null
+ && mLoadedImageUri == null
+ && mBitmap == null
+ && mImageResource == 0) {
+
+ Uri uri = bundle.getParcelable("LOADED_IMAGE_URI");
+ if (uri != null) {
+ String key = bundle.getString("LOADED_IMAGE_STATE_BITMAP_KEY");
+ if (key != null) {
+ Bitmap stateBitmap =
+ BitmapUtils.mStateBitmap != null && BitmapUtils.mStateBitmap.first.equals(key)
+ ? BitmapUtils.mStateBitmap.second.get()
+ : null;
+ BitmapUtils.mStateBitmap = null;
+ if (stateBitmap != null && !stateBitmap.isRecycled()) {
+ setBitmap(stateBitmap, 0, uri, bundle.getInt("LOADED_SAMPLE_SIZE"), 0);
+ }
+ }
+ if (mLoadedImageUri == null) {
+ setImageUriAsync(uri);
+ }
+ } else {
+ int resId = bundle.getInt("LOADED_IMAGE_RESOURCE");
+ if (resId > 0) {
+ setImageResource(resId);
+ } else {
+ uri = bundle.getParcelable("LOADING_IMAGE_URI");
+ if (uri != null) {
+ setImageUriAsync(uri);
+ }
+ }
+ }
+
+ mDegreesRotated = mRestoreDegreesRotated = bundle.getInt("DEGREES_ROTATED");
+
+ Rect initialCropRect = bundle.getParcelable("INITIAL_CROP_RECT");
+ if (initialCropRect != null
+ && (initialCropRect.width() > 0 || initialCropRect.height() > 0)) {
+ mCropOverlayView.setInitialCropWindowRect(initialCropRect);
+ }
+
+ RectF cropWindowRect = bundle.getParcelable("CROP_WINDOW_RECT");
+ if (cropWindowRect != null && (cropWindowRect.width() > 0 || cropWindowRect.height() > 0)) {
+ mRestoreCropWindowRect = cropWindowRect;
+ }
+
+ mCropOverlayView.setCropShape(CropShape.valueOf(bundle.getString("CROP_SHAPE")));
+
+ mAutoZoomEnabled = bundle.getBoolean("CROP_AUTO_ZOOM_ENABLED");
+ mMaxZoom = bundle.getInt("CROP_MAX_ZOOM");
+
+ mFlipHorizontally = bundle.getBoolean("CROP_FLIP_HORIZONTALLY");
+ mFlipVertically = bundle.getBoolean("CROP_FLIP_VERTICALLY");
+ }
+
+ super.onRestoreInstanceState(bundle.getParcelable("instanceState"));
+ } else {
+ super.onRestoreInstanceState(state);
+ }
+ }
+
+ @Override
+ protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
+ super.onMeasure(widthMeasureSpec, heightMeasureSpec);
+
+ int widthMode = MeasureSpec.getMode(widthMeasureSpec);
+ int widthSize = MeasureSpec.getSize(widthMeasureSpec);
+ int heightMode = MeasureSpec.getMode(heightMeasureSpec);
+ int heightSize = MeasureSpec.getSize(heightMeasureSpec);
+
+ if (mBitmap != null) {
+
+ // Bypasses a baffling bug when used within a ScrollView, where heightSize is set to 0.
+ if (heightSize == 0) {
+ heightSize = mBitmap.getHeight();
+ }
+
+ int desiredWidth;
+ int desiredHeight;
+
+ double viewToBitmapWidthRatio = Double.POSITIVE_INFINITY;
+ double viewToBitmapHeightRatio = Double.POSITIVE_INFINITY;
+
+ // Checks if either width or height needs to be fixed
+ if (widthSize < mBitmap.getWidth()) {
+ viewToBitmapWidthRatio = (double) widthSize / (double) mBitmap.getWidth();
+ }
+ if (heightSize < mBitmap.getHeight()) {
+ viewToBitmapHeightRatio = (double) heightSize / (double) mBitmap.getHeight();
+ }
+
+ // If either needs to be fixed, choose smallest ratio and calculate from there
+ if (viewToBitmapWidthRatio != Double.POSITIVE_INFINITY
+ || viewToBitmapHeightRatio != Double.POSITIVE_INFINITY) {
+ if (viewToBitmapWidthRatio <= viewToBitmapHeightRatio) {
+ desiredWidth = widthSize;
+ desiredHeight = (int) (mBitmap.getHeight() * viewToBitmapWidthRatio);
+ } else {
+ desiredHeight = heightSize;
+ desiredWidth = (int) (mBitmap.getWidth() * viewToBitmapHeightRatio);
+ }
+ } else {
+ // Otherwise, the picture is within frame layout bounds. Desired width is simply picture
+ // size
+ desiredWidth = mBitmap.getWidth();
+ desiredHeight = mBitmap.getHeight();
+ }
+
+ int width = getOnMeasureSpec(widthMode, widthSize, desiredWidth);
+ int height = getOnMeasureSpec(heightMode, heightSize, desiredHeight);
+
+ mLayoutWidth = width;
+ mLayoutHeight = height;
+
+ setMeasuredDimension(mLayoutWidth, mLayoutHeight);
+
+ } else {
+ setMeasuredDimension(widthSize, heightSize);
+ }
+ }
+
+ @Override
+ protected void onLayout(boolean changed, int l, int t, int r, int b) {
+
+ super.onLayout(changed, l, t, r, b);
+
+ if (mLayoutWidth > 0 && mLayoutHeight > 0) {
+ // Gets original parameters, and creates the new parameters
+ ViewGroup.LayoutParams origParams = this.getLayoutParams();
+ origParams.width = mLayoutWidth;
+ origParams.height = mLayoutHeight;
+ setLayoutParams(origParams);
+
+ if (mBitmap != null) {
+ applyImageMatrix(r - l, b - t, true, false);
+
+ // after state restore we want to restore the window crop, possible only after widget size
+ // is known
+ if (mRestoreCropWindowRect != null) {
+ if (mRestoreDegreesRotated != mInitialDegreesRotated) {
+ mDegreesRotated = mRestoreDegreesRotated;
+ applyImageMatrix(r - l, b - t, true, false);
+ }
+ mImageMatrix.mapRect(mRestoreCropWindowRect);
+ mCropOverlayView.setCropWindowRect(mRestoreCropWindowRect);
+ handleCropWindowChanged(false, false);
+ mCropOverlayView.fixCurrentCropWindowRect();
+ mRestoreCropWindowRect = null;
+ } else if (mSizeChanged) {
+ mSizeChanged = false;
+ handleCropWindowChanged(false, false);
+ }
+ } else {
+ updateImageBounds(true);
+ }
+ } else {
+ updateImageBounds(true);
+ }
+ }
+
+ /**
+ * Detect size change to handle auto-zoom using {@link #handleCropWindowChanged(boolean, boolean)}
+ * in {@link #layout(int, int, int, int)}.
+ */
+ @Override
+ protected void onSizeChanged(int w, int h, int oldw, int oldh) {
+ super.onSizeChanged(w, h, oldw, oldh);
+ mSizeChanged = oldw > 0 && oldh > 0;
+ }
+
+ /**
+ * Handle crop window change to:
+ * 1. Execute auto-zoom-in/out depending on the area covered of cropping window relative to the
+ * available view area.
+ * 2. Slide the zoomed sub-area if the cropping window is outside of the visible view sub-area.
+ *
+ *
+ * @param inProgress is the crop window change is still in progress by the user
+ * @param animate if to animate the change to the image matrix, or set it directly
+ */
+ private void handleCropWindowChanged(boolean inProgress, boolean animate) {
+ int width = getWidth();
+ int height = getHeight();
+ if (mBitmap != null && width > 0 && height > 0) {
+
+ RectF cropRect = mCropOverlayView.getCropWindowRect();
+ if (inProgress) {
+ if (cropRect.left < 0
+ || cropRect.top < 0
+ || cropRect.right > width
+ || cropRect.bottom > height) {
+ applyImageMatrix(width, height, false, false);
+ }
+ } else if (mAutoZoomEnabled || mZoom > 1) {
+ float newZoom = 0;
+ // keep the cropping window covered area to 50%-65% of zoomed sub-area
+ if (mZoom < mMaxZoom
+ && cropRect.width() < width * 0.5f
+ && cropRect.height() < height * 0.5f) {
+ newZoom =
+ Math.min(
+ mMaxZoom,
+ Math.min(
+ width / (cropRect.width() / mZoom / 0.64f),
+ height / (cropRect.height() / mZoom / 0.64f)));
+ }
+ if (mZoom > 1 && (cropRect.width() > width * 0.65f || cropRect.height() > height * 0.65f)) {
+ newZoom =
+ Math.max(
+ 1,
+ Math.min(
+ width / (cropRect.width() / mZoom / 0.51f),
+ height / (cropRect.height() / mZoom / 0.51f)));
+ }
+ if (!mAutoZoomEnabled) {
+ newZoom = 1;
+ }
+
+ if (newZoom > 0 && newZoom != mZoom) {
+ if (animate) {
+ if (mAnimation == null) {
+ // lazy create animation single instance
+ mAnimation = new CropImageAnimation(mImageView, mCropOverlayView);
+ }
+ // set the state for animation to start from
+ mAnimation.setStartState(mImagePoints, mImageMatrix);
+ }
+
+ mZoom = newZoom;
+
+ applyImageMatrix(width, height, true, animate);
+ }
+ }
+ if (mOnSetCropWindowChangeListener != null && !inProgress) {
+ mOnSetCropWindowChangeListener.onCropWindowChanged();
+ }
+ }
+ }
+
+ /**
+ * Apply matrix to handle the image inside the image view.
+ *
+ * @param width the width of the image view
+ * @param height the height of the image view
+ */
+ private void applyImageMatrix(float width, float height, boolean center, boolean animate) {
+ if (mBitmap != null && width > 0 && height > 0) {
+
+ mImageMatrix.invert(mImageInverseMatrix);
+ RectF cropRect = mCropOverlayView.getCropWindowRect();
+ mImageInverseMatrix.mapRect(cropRect);
+
+ mImageMatrix.reset();
+
+ // move the image to the center of the image view first so we can manipulate it from there
+ mImageMatrix.postTranslate(
+ (width - mBitmap.getWidth()) / 2, (height - mBitmap.getHeight()) / 2);
+ mapImagePointsByImageMatrix();
+
+ // rotate the image the required degrees from center of image
+ if (mDegreesRotated > 0) {
+ mImageMatrix.postRotate(
+ mDegreesRotated,
+ BitmapUtils.getRectCenterX(mImagePoints),
+ BitmapUtils.getRectCenterY(mImagePoints));
+ mapImagePointsByImageMatrix();
+ }
+
+ // scale the image to the image view, image rect transformed to know new width/height
+ float scale =
+ Math.min(
+ width / BitmapUtils.getRectWidth(mImagePoints),
+ height / BitmapUtils.getRectHeight(mImagePoints));
+ if (mScaleType == ScaleType.FIT_CENTER
+ || (mScaleType == ScaleType.CENTER_INSIDE && scale < 1)
+ || (scale > 1 && mAutoZoomEnabled)) {
+ mImageMatrix.postScale(
+ scale,
+ scale,
+ BitmapUtils.getRectCenterX(mImagePoints),
+ BitmapUtils.getRectCenterY(mImagePoints));
+ mapImagePointsByImageMatrix();
+ }
+
+ // scale by the current zoom level
+ float scaleX = mFlipHorizontally ? -mZoom : mZoom;
+ float scaleY = mFlipVertically ? -mZoom : mZoom;
+ mImageMatrix.postScale(
+ scaleX,
+ scaleY,
+ BitmapUtils.getRectCenterX(mImagePoints),
+ BitmapUtils.getRectCenterY(mImagePoints));
+ mapImagePointsByImageMatrix();
+
+ mImageMatrix.mapRect(cropRect);
+
+ if (center) {
+ // set the zoomed area to be as to the center of cropping window as possible
+ mZoomOffsetX =
+ width > BitmapUtils.getRectWidth(mImagePoints)
+ ? 0
+ : Math.max(
+ Math.min(
+ width / 2 - cropRect.centerX(), -BitmapUtils.getRectLeft(mImagePoints)),
+ getWidth() - BitmapUtils.getRectRight(mImagePoints))
+ / scaleX;
+ mZoomOffsetY =
+ height > BitmapUtils.getRectHeight(mImagePoints)
+ ? 0
+ : Math.max(
+ Math.min(
+ height / 2 - cropRect.centerY(), -BitmapUtils.getRectTop(mImagePoints)),
+ getHeight() - BitmapUtils.getRectBottom(mImagePoints))
+ / scaleY;
+ } else {
+ // adjust the zoomed area so the crop window rectangle will be inside the area in case it
+ // was moved outside
+ mZoomOffsetX =
+ Math.min(Math.max(mZoomOffsetX * scaleX, -cropRect.left), -cropRect.right + width)
+ / scaleX;
+ mZoomOffsetY =
+ Math.min(Math.max(mZoomOffsetY * scaleY, -cropRect.top), -cropRect.bottom + height)
+ / scaleY;
+ }
+
+ // apply to zoom offset translate and update the crop rectangle to offset correctly
+ mImageMatrix.postTranslate(mZoomOffsetX * scaleX, mZoomOffsetY * scaleY);
+ cropRect.offset(mZoomOffsetX * scaleX, mZoomOffsetY * scaleY);
+ mCropOverlayView.setCropWindowRect(cropRect);
+ mapImagePointsByImageMatrix();
+ mCropOverlayView.invalidate();
+
+ // set matrix to apply
+ if (animate) {
+ // set the state for animation to end in, start animation now
+ mAnimation.setEndState(mImagePoints, mImageMatrix);
+ mImageView.startAnimation(mAnimation);
+ } else {
+ mImageView.setImageMatrix(mImageMatrix);
+ }
+
+ // update the image rectangle in the crop overlay
+ updateImageBounds(false);
+ }
+ }
+
+ /**
+ * Adjust the given image rectangle by image transformation matrix to know the final rectangle of
+ * the image.
+ * To get the proper rectangle it must be first reset to original image rectangle.
+ */
+ private void mapImagePointsByImageMatrix() {
+ mImagePoints[0] = 0;
+ mImagePoints[1] = 0;
+ mImagePoints[2] = mBitmap.getWidth();
+ mImagePoints[3] = 0;
+ mImagePoints[4] = mBitmap.getWidth();
+ mImagePoints[5] = mBitmap.getHeight();
+ mImagePoints[6] = 0;
+ mImagePoints[7] = mBitmap.getHeight();
+ mImageMatrix.mapPoints(mImagePoints);
+ mScaleImagePoints[0] = 0;
+ mScaleImagePoints[1] = 0;
+ mScaleImagePoints[2] = 100;
+ mScaleImagePoints[3] = 0;
+ mScaleImagePoints[4] = 100;
+ mScaleImagePoints[5] = 100;
+ mScaleImagePoints[6] = 0;
+ mScaleImagePoints[7] = 100;
+ mImageMatrix.mapPoints(mScaleImagePoints);
+ }
+
+ /**
+ * Determines the specs for the onMeasure function. Calculates the width or height depending on
+ * the mode.
+ *
+ * @param measureSpecMode The mode of the measured width or height.
+ * @param measureSpecSize The size of the measured width or height.
+ * @param desiredSize The desired size of the measured width or height.
+ * @return The final size of the width or height.
+ */
+ private static int getOnMeasureSpec(int measureSpecMode, int measureSpecSize, int desiredSize) {
+
+ // Measure Width
+ int spec;
+ if (measureSpecMode == MeasureSpec.EXACTLY) {
+ // Must be this size
+ spec = measureSpecSize;
+ } else if (measureSpecMode == MeasureSpec.AT_MOST) {
+ // Can't be bigger than...; match_parent value
+ spec = Math.min(desiredSize, measureSpecSize);
+ } else {
+ // Be whatever you want; wrap_content
+ spec = desiredSize;
+ }
+
+ return spec;
+ }
+
+ /**
+ * Set visibility of crop overlay to hide it when there is no image or specificly set by client.
+ */
+ private void setCropOverlayVisibility() {
+ if (mCropOverlayView != null) {
+ mCropOverlayView.setVisibility(mShowCropOverlay && mBitmap != null ? VISIBLE : INVISIBLE);
+ }
+ }
+
+ /**
+ * Set visibility of progress bar when async loading/cropping is in process and show is enabled.
+ */
+ private void setProgressBarVisibility() {
+ boolean visible =
+ mShowProgressBar
+ && (mBitmap == null && mBitmapLoadingWorkerTask != null
+ || mBitmapCroppingWorkerTask != null);
+ mProgressBar.setVisibility(visible ? VISIBLE : INVISIBLE);
+ }
+
+ /** Update the scale factor between the actual image bitmap and the shown image.
*/
+ private void updateImageBounds(boolean clear) {
+ if (mBitmap != null && !clear) {
+
+ // Get the scale factor between the actual Bitmap dimensions and the displayed dimensions for
+ // width/height.
+ float scaleFactorWidth =
+ 100f * mLoadedSampleSize / BitmapUtils.getRectWidth(mScaleImagePoints);
+ float scaleFactorHeight =
+ 100f * mLoadedSampleSize / BitmapUtils.getRectHeight(mScaleImagePoints);
+ mCropOverlayView.setCropWindowLimits(
+ getWidth(), getHeight(), scaleFactorWidth, scaleFactorHeight);
+ }
+
+ // set the bitmap rectangle and update the crop window after scale factor is set
+ mCropOverlayView.setBounds(clear ? null : mImagePoints, getWidth(), getHeight());
+ }
+ // endregion
+
+ // region: Inner class: CropShape
+
+ /**
+ * The possible cropping area shape.
+ * To set square/circle crop shape set aspect ratio to 1:1.
+ */
+ public enum CropShape {
+ RECTANGLE,
+ OVAL
+ }
+ // endregion
+
+ // region: Inner class: ScaleType
+
+ /**
+ * Options for scaling the bounds of cropping image to the bounds of Crop Image View.
+ * Note: Some options are affected by auto-zoom, if enabled.
+ */
+ public enum ScaleType {
+
+ /**
+ * Scale the image uniformly (maintain the image's aspect ratio) to fit in crop image view.
+ * The largest dimension will be equals to crop image view and the second dimension will be
+ * smaller.
+ */
+ FIT_CENTER,
+
+ /**
+ * Center the image in the view, but perform no scaling.
+ * Note: If auto-zoom is enabled and the source image is smaller than crop image view then it
+ * will be scaled uniformly to fit the crop image view.
+ */
+ CENTER,
+
+ /**
+ * Scale the image uniformly (maintain the image's aspect ratio) so that both dimensions (width
+ * and height) of the image will be equal to or larger than the corresponding dimension
+ * of the view (minus padding).
+ * The image is then centered in the view.
+ */
+ CENTER_CROP,
+
+ /**
+ * Scale the image uniformly (maintain the image's aspect ratio) so that both dimensions (width
+ * and height) of the image will be equal to or less than the corresponding dimension of
+ * the view (minus padding).
+ * The image is then centered in the view.
+ * Note: If auto-zoom is enabled and the source image is smaller than crop image view then it
+ * will be scaled uniformly to fit the crop image view.
+ */
+ CENTER_INSIDE
+ }
+ // endregion
+
+ // region: Inner class: Guidelines
+
+ /** The possible guidelines showing types. */
+ public enum Guidelines {
+ /** Never show */
+ OFF,
+
+ /** Show when crop move action is live */
+ ON_TOUCH,
+
+ /** Always show */
+ ON
+ }
+ // endregion
+
+ // region: Inner class: RequestSizeOptions
+
+ /** Possible options for handling requested width/height for cropping. */
+ public enum RequestSizeOptions {
+
+ /** No resize/sampling is done unless required for memory management (OOM). */
+ NONE,
+
+ /**
+ * Only sample the image during loading (if image set using URI) so the smallest of the image
+ * dimensions will be between the requested size and x2 requested size.
+ * NOTE: resulting image will not be exactly requested width/height see: Loading
+ * Large Bitmaps Efficiently.
+ */
+ SAMPLING,
+
+ /**
+ * Resize the image uniformly (maintain the image's aspect ratio) so that both dimensions (width
+ * and height) of the image will be equal to or less than the corresponding requested
+ * dimension.
+ * If the image is smaller than the requested size it will NOT change.
+ */
+ RESIZE_INSIDE,
+
+ /**
+ * Resize the image uniformly (maintain the image's aspect ratio) to fit in the given
+ * width/height.
+ * The largest dimension will be equals to the requested and the second dimension will be
+ * smaller.
+ * If the image is smaller than the requested size it will enlarge it.
+ */
+ RESIZE_FIT,
+
+ /**
+ * Resize the image to fit exactly in the given width/height.
+ * This resize method does NOT preserve aspect ratio.
+ * If the image is smaller than the requested size it will enlarge it.
+ */
+ RESIZE_EXACT
+ }
+ // endregion
+
+ // region: Inner class: OnSetImageUriCompleteListener
+
+ /** Interface definition for a callback to be invoked when the crop overlay is released. */
+ public interface OnSetCropOverlayReleasedListener {
+
+ /**
+ * Called when the crop overlay changed listener is called and inProgress is false.
+ *
+ * @param rect The rect coordinates of the cropped overlay
+ */
+ void onCropOverlayReleased(Rect rect);
+ }
+
+ /** Interface definition for a callback to be invoked when the crop overlay is released. */
+ public interface OnSetCropOverlayMovedListener {
+
+ /**
+ * Called when the crop overlay is moved
+ *
+ * @param rect The rect coordinates of the cropped overlay
+ */
+ void onCropOverlayMoved(Rect rect);
+ }
+
+ /** Interface definition for a callback to be invoked when the crop overlay is released. */
+ public interface OnSetCropWindowChangeListener {
+
+ /** Called when the crop window is changed */
+ void onCropWindowChanged();
+ }
+
+ /** Interface definition for a callback to be invoked when image async loading is complete. */
+ public interface OnSetImageUriCompleteListener {
+
+ /**
+ * Called when a crop image view has completed loading image for cropping.
+ * If loading failed error parameter will contain the error.
+ *
+ * @param view The crop image view that loading of image was complete.
+ * @param uri the URI of the image that was loading
+ * @param error if error occurred during loading will contain the error, otherwise null.
+ */
+ void onSetImageUriComplete(CropImageView view, Uri uri, Exception error);
+ }
+ // endregion
+
+ // region: Inner class: OnGetCroppedImageCompleteListener
+
+ /** Interface definition for a callback to be invoked when image async crop is complete. */
+ public interface OnCropImageCompleteListener {
+
+ /**
+ * Called when a crop image view has completed cropping image.
+ * Result object contains the cropped bitmap, saved cropped image uri, crop points data or the
+ * error occured during cropping.
+ *
+ * @param view The crop image view that cropping of image was complete.
+ * @param result the crop image result data (with cropped image or error)
+ */
+ void onCropImageComplete(CropImageView view, CropResult result);
+ }
+ // endregion
+
+ // region: Inner class: ActivityResult
+
+ /** Result data of crop image. */
+ public static class CropResult {
+
+ /**
+ * The image bitmap of the original image loaded for cropping.
+ * Null if uri used to load image or activity result is used.
+ */
+ private final Bitmap mOriginalBitmap;
+
+ /**
+ * The Android uri of the original image loaded for cropping.
+ * Null if bitmap was used to load image.
+ */
+ private final Uri mOriginalUri;
+
+ /**
+ * The cropped image bitmap result.
+ * Null if save cropped image was executed, no output requested or failure.
+ */
+ private final Bitmap mBitmap;
+
+ /**
+ * The Android uri of the saved cropped image result.
+ * Null if get cropped image was executed, no output requested or failure.
+ */
+ private final Uri mUri;
+
+ /** The error that failed the loading/cropping (null if successful) */
+ private final Exception mError;
+
+ /** The 4 points of the cropping window in the source image */
+ private final float[] mCropPoints;
+
+ /** The rectangle of the cropping window in the source image */
+ private final Rect mCropRect;
+
+ /** The rectangle of the source image dimensions */
+ private final Rect mWholeImageRect;
+
+ /** The final rotation of the cropped image relative to source */
+ private final int mRotation;
+
+ /** sample size used creating the crop bitmap to lower its size */
+ private final int mSampleSize;
+
+ CropResult(
+ Bitmap originalBitmap,
+ Uri originalUri,
+ Bitmap bitmap,
+ Uri uri,
+ Exception error,
+ float[] cropPoints,
+ Rect cropRect,
+ Rect wholeImageRect,
+ int rotation,
+ int sampleSize) {
+ mOriginalBitmap = originalBitmap;
+ mOriginalUri = originalUri;
+ mBitmap = bitmap;
+ mUri = uri;
+ mError = error;
+ mCropPoints = cropPoints;
+ mCropRect = cropRect;
+ mWholeImageRect = wholeImageRect;
+ mRotation = rotation;
+ mSampleSize = sampleSize;
+ }
+
+ /**
+ * The image bitmap of the original image loaded for cropping.
+ * Null if uri used to load image or activity result is used.
+ */
+ public Bitmap getOriginalBitmap() {
+ return mOriginalBitmap;
+ }
+
+ /**
+ * The Android uri of the original image loaded for cropping.
+ * Null if bitmap was used to load image.
+ */
+ public Uri getOriginalUri() {
+ return mOriginalUri;
+ }
+
+ /** Is the result is success or error. */
+ public boolean isSuccessful() {
+ return mError == null;
+ }
+
+ /**
+ * The cropped image bitmap result.
+ * Null if save cropped image was executed, no output requested or failure.
+ */
+ public Bitmap getBitmap() {
+ return mBitmap;
+ }
+
+ /**
+ * The Android uri of the saved cropped image result Null if get cropped image was executed, no
+ * output requested or failure.
+ */
+ public Uri getUri() {
+ return mUri;
+ }
+
+ /** The error that failed the loading/cropping (null if successful) */
+ public Exception getError() {
+ return mError;
+ }
+
+ /** The 4 points of the cropping window in the source image */
+ public float[] getCropPoints() {
+ return mCropPoints;
+ }
+
+ /** The rectangle of the cropping window in the source image */
+ public Rect getCropRect() {
+ return mCropRect;
+ }
+
+ /** The rectangle of the source image dimensions */
+ public Rect getWholeImageRect() {
+ return mWholeImageRect;
+ }
+
+ /** The final rotation of the cropped image relative to source */
+ public int getRotation() {
+ return mRotation;
+ }
+
+ /** sample size used creating the crop bitmap to lower its size */
+ public int getSampleSize() {
+ return mSampleSize;
+ }
+ }
+ // endregion
+}
diff --git a/cropper/src/main/java/com/theartofdev/edmodo/cropper/CropOverlayView.java b/cropper/src/main/java/com/theartofdev/edmodo/cropper/CropOverlayView.java
new file mode 100644
index 0000000..542c95a
--- /dev/null
+++ b/cropper/src/main/java/com/theartofdev/edmodo/cropper/CropOverlayView.java
@@ -0,0 +1,1040 @@
+// "Therefore those skilled at the unorthodox
+// are infinite as heaven and earth,
+// inexhaustible as the great rivers.
+// When they come to an end,
+// they begin again,
+// like the days and months;
+// they die and are reborn,
+// like the four seasons."
+//
+// - Sun Tsu,
+// "The Art of War"
+
+package com.theartofdev.edmodo.cropper;
+
+import android.annotation.TargetApi;
+import android.content.Context;
+import android.graphics.Canvas;
+import android.graphics.Paint;
+import android.graphics.Path;
+import android.graphics.Rect;
+import android.graphics.RectF;
+import android.graphics.Region;
+import android.os.Build;
+import android.util.AttributeSet;
+import android.util.Log;
+import android.view.MotionEvent;
+import android.view.ScaleGestureDetector;
+import android.view.View;
+
+import java.util.Arrays;
+
+/** A custom View representing the crop window and the shaded background outside the crop window. */
+public class CropOverlayView extends View {
+
+ // region: Fields and Consts
+
+ /** Gesture detector used for multi touch box scaling */
+ private ScaleGestureDetector mScaleDetector;
+
+ /** Boolean to see if multi touch is enabled for the crop rectangle */
+ private boolean mMultiTouchEnabled;
+
+ /** Handler from crop window stuff, moving and knowing possition. */
+ private final CropWindowHandler mCropWindowHandler = new CropWindowHandler();
+
+ /** Listener to publicj crop window changes */
+ private CropWindowChangeListener mCropWindowChangeListener;
+
+ /** Rectangle used for drawing */
+ private final RectF mDrawRect = new RectF();
+
+ /** The Paint used to draw the white rectangle around the crop area. */
+ private Paint mBorderPaint;
+
+ /** The Paint used to draw the corners of the Border */
+ private Paint mBorderCornerPaint;
+
+ /** The Paint used to draw the guidelines within the crop area when pressed. */
+ private Paint mGuidelinePaint;
+
+ /** The Paint used to darken the surrounding areas outside the crop area. */
+ private Paint mBackgroundPaint;
+
+ /** Used for oval crop window shape or non-straight rotation drawing. */
+ private Path mPath = new Path();
+
+ /** The bounding box around the Bitmap that we are cropping. */
+ private final float[] mBoundsPoints = new float[8];
+
+ /** The bounding box around the Bitmap that we are cropping. */
+ private final RectF mCalcBounds = new RectF();
+
+ /** The bounding image view width used to know the crop overlay is at view edges. */
+ private int mViewWidth;
+
+ /** The bounding image view height used to know the crop overlay is at view edges. */
+ private int mViewHeight;
+
+ /** The offset to draw the border corener from the border */
+ private float mBorderCornerOffset;
+
+ /** the length of the border corner to draw */
+ private float mBorderCornerLength;
+
+ /** The initial crop window padding from image borders */
+ private float mInitialCropWindowPaddingRatio;
+
+ /** The radius of the touch zone (in pixels) around a given Handle. */
+ private float mTouchRadius;
+
+ /**
+ * An edge of the crop window will snap to the corresponding edge of a specified bounding box when
+ * the crop window edge is less than or equal to this distance (in pixels) away from the bounding
+ * box edge.
+ */
+ private float mSnapRadius;
+
+ /** The Handle that is currently pressed; null if no Handle is pressed. */
+ private CropWindowMoveHandler mMoveHandler;
+
+ /**
+ * Flag indicating if the crop area should always be a certain aspect ratio (indicated by
+ * mTargetAspectRatio).
+ */
+ private boolean mFixAspectRatio;
+
+ /** save the current aspect ratio of the image */
+ private int mAspectRatioX;
+
+ /** save the current aspect ratio of the image */
+ private int mAspectRatioY;
+
+ /**
+ * The aspect ratio that the crop area should maintain; this variable is only used when
+ * mMaintainAspectRatio is true.
+ */
+ private float mTargetAspectRatio = ((float) mAspectRatioX) / mAspectRatioY;
+
+ /** Instance variables for customizable attributes */
+ private CropImageView.Guidelines mGuidelines;
+
+ /** The shape of the cropping area - rectangle/circular. */
+ private CropImageView.CropShape mCropShape;
+
+ /** the initial crop window rectangle to set */
+ private final Rect mInitialCropWindowRect = new Rect();
+
+ /** Whether the Crop View has been initialized for the first time */
+ private boolean initializedCropWindow;
+
+ /** Used to set back LayerType after changing to software. */
+ private Integer mOriginalLayerType;
+ // endregion
+
+ public CropOverlayView(Context context) {
+ this(context, null);
+ }
+
+ public CropOverlayView(Context context, AttributeSet attrs) {
+ super(context, attrs);
+ }
+
+ /** Set the crop window change listener. */
+ public void setCropWindowChangeListener(CropWindowChangeListener listener) {
+ mCropWindowChangeListener = listener;
+ }
+
+ /** Get the left/top/right/bottom coordinates of the crop window. */
+ public RectF getCropWindowRect() {
+ return mCropWindowHandler.getRect();
+ }
+
+ /** Set the left/top/right/bottom coordinates of the crop window. */
+ public void setCropWindowRect(RectF rect) {
+ mCropWindowHandler.setRect(rect);
+ }
+
+ /** Fix the current crop window rectangle if it is outside of cropping image or view bounds. */
+ public void fixCurrentCropWindowRect() {
+ RectF rect = getCropWindowRect();
+ fixCropWindowRectByRules(rect);
+ mCropWindowHandler.setRect(rect);
+ }
+
+ /**
+ * Informs the CropOverlayView of the image's position relative to the ImageView. This is
+ * necessary to call in order to draw the crop window.
+ *
+ * @param boundsPoints the image's bounding points
+ * @param viewWidth The bounding image view width.
+ * @param viewHeight The bounding image view height.
+ */
+ public void setBounds(float[] boundsPoints, int viewWidth, int viewHeight) {
+ if (boundsPoints == null || !Arrays.equals(mBoundsPoints, boundsPoints)) {
+ if (boundsPoints == null) {
+ Arrays.fill(mBoundsPoints, 0);
+ } else {
+ System.arraycopy(boundsPoints, 0, mBoundsPoints, 0, boundsPoints.length);
+ }
+ mViewWidth = viewWidth;
+ mViewHeight = viewHeight;
+ RectF cropRect = mCropWindowHandler.getRect();
+ if (cropRect.width() == 0 || cropRect.height() == 0) {
+ initCropWindow();
+ }
+ }
+ }
+
+ /** Resets the crop overlay view. */
+ public void resetCropOverlayView() {
+ if (initializedCropWindow) {
+ setCropWindowRect(BitmapUtils.EMPTY_RECT_F);
+ initCropWindow();
+ invalidate();
+ }
+ }
+
+ /** The shape of the cropping area - rectangle/circular. */
+ public CropImageView.CropShape getCropShape() {
+ return mCropShape;
+ }
+
+ /** The shape of the cropping area - rectangle/circular. */
+ public void setCropShape(CropImageView.CropShape cropShape) {
+ if (mCropShape != cropShape) {
+ mCropShape = cropShape;
+ if (Build.VERSION.SDK_INT <= 17) {
+ if (mCropShape == CropImageView.CropShape.OVAL) {
+ mOriginalLayerType = getLayerType();
+ if (mOriginalLayerType != View.LAYER_TYPE_SOFTWARE) {
+ // TURN off hardware acceleration
+ setLayerType(View.LAYER_TYPE_SOFTWARE, null);
+ } else {
+ mOriginalLayerType = null;
+ }
+ } else if (mOriginalLayerType != null) {
+ // return hardware acceleration back
+ setLayerType(mOriginalLayerType, null);
+ mOriginalLayerType = null;
+ }
+ }
+ invalidate();
+ }
+ }
+
+ /** Get the current guidelines option set. */
+ public CropImageView.Guidelines getGuidelines() {
+ return mGuidelines;
+ }
+
+ /**
+ * Sets the guidelines for the CropOverlayView to be either on, off, or to show when resizing the
+ * application.
+ */
+ public void setGuidelines(CropImageView.Guidelines guidelines) {
+ if (mGuidelines != guidelines) {
+ mGuidelines = guidelines;
+ if (initializedCropWindow) {
+ invalidate();
+ }
+ }
+ }
+
+ /**
+ * whether the aspect ratio is fixed or not; true fixes the aspect ratio, while false allows it to
+ * be changed.
+ */
+ public boolean isFixAspectRatio() {
+ return mFixAspectRatio;
+ }
+
+ /**
+ * Sets whether the aspect ratio is fixed or not; true fixes the aspect ratio, while false allows
+ * it to be changed.
+ */
+ public void setFixedAspectRatio(boolean fixAspectRatio) {
+ if (mFixAspectRatio != fixAspectRatio) {
+ mFixAspectRatio = fixAspectRatio;
+ if (initializedCropWindow) {
+ initCropWindow();
+ invalidate();
+ }
+ }
+ }
+
+ /** the X value of the aspect ratio; */
+ public int getAspectRatioX() {
+ return mAspectRatioX;
+ }
+
+ /** Sets the X value of the aspect ratio; is defaulted to 1. */
+ public void setAspectRatioX(int aspectRatioX) {
+ if (aspectRatioX <= 0) {
+ throw new IllegalArgumentException(
+ "Cannot set aspect ratio value to a number less than or equal to 0.");
+ } else if (mAspectRatioX != aspectRatioX) {
+ mAspectRatioX = aspectRatioX;
+ mTargetAspectRatio = ((float) mAspectRatioX) / mAspectRatioY;
+
+ if (initializedCropWindow) {
+ initCropWindow();
+ invalidate();
+ }
+ }
+ }
+
+ /** the Y value of the aspect ratio; */
+ public int getAspectRatioY() {
+ return mAspectRatioY;
+ }
+
+ /**
+ * Sets the Y value of the aspect ratio; is defaulted to 1.
+ *
+ * @param aspectRatioY int that specifies the new Y value of the aspect ratio
+ */
+ public void setAspectRatioY(int aspectRatioY) {
+ if (aspectRatioY <= 0) {
+ throw new IllegalArgumentException(
+ "Cannot set aspect ratio value to a number less than or equal to 0.");
+ } else if (mAspectRatioY != aspectRatioY) {
+ mAspectRatioY = aspectRatioY;
+ mTargetAspectRatio = ((float) mAspectRatioX) / mAspectRatioY;
+
+ if (initializedCropWindow) {
+ initCropWindow();
+ invalidate();
+ }
+ }
+ }
+
+ /**
+ * An edge of the crop window will snap to the corresponding edge of a specified bounding box when
+ * the crop window edge is less than or equal to this distance (in pixels) away from the bounding
+ * box edge. (default: 3)
+ */
+ public void setSnapRadius(float snapRadius) {
+ mSnapRadius = snapRadius;
+ }
+
+ /** Set multi touch functionality to enabled/disabled. */
+ public boolean setMultiTouchEnabled(boolean multiTouchEnabled) {
+ if (mMultiTouchEnabled != multiTouchEnabled) {
+ mMultiTouchEnabled = multiTouchEnabled;
+ if (mMultiTouchEnabled && mScaleDetector == null) {
+ mScaleDetector = new ScaleGestureDetector(getContext(), new ScaleListener());
+ }
+ return true;
+ }
+ return false;
+ }
+
+ /**
+ * the min size the resulting cropping image is allowed to be, affects the cropping window limits
+ * (in pixels).
+ */
+ public void setMinCropResultSize(int minCropResultWidth, int minCropResultHeight) {
+ mCropWindowHandler.setMinCropResultSize(minCropResultWidth, minCropResultHeight);
+ }
+
+ /**
+ * the max size the resulting cropping image is allowed to be, affects the cropping window limits
+ * (in pixels).
+ */
+ public void setMaxCropResultSize(int maxCropResultWidth, int maxCropResultHeight) {
+ mCropWindowHandler.setMaxCropResultSize(maxCropResultWidth, maxCropResultHeight);
+ }
+
+ /**
+ * set the max width/height and scale factor of the shown image to original image to scale the
+ * limits appropriately.
+ */
+ public void setCropWindowLimits(
+ float maxWidth, float maxHeight, float scaleFactorWidth, float scaleFactorHeight) {
+ mCropWindowHandler.setCropWindowLimits(
+ maxWidth, maxHeight, scaleFactorWidth, scaleFactorHeight);
+ }
+
+ /** Get crop window initial rectangle. */
+ public Rect getInitialCropWindowRect() {
+ return mInitialCropWindowRect;
+ }
+
+ /** Set crop window initial rectangle to be used instead of default. */
+ public void setInitialCropWindowRect(Rect rect) {
+ mInitialCropWindowRect.set(rect != null ? rect : BitmapUtils.EMPTY_RECT);
+ if (initializedCropWindow) {
+ initCropWindow();
+ invalidate();
+ callOnCropWindowChanged(false);
+ }
+ }
+
+ /** Reset crop window to initial rectangle. */
+ public void resetCropWindowRect() {
+ if (initializedCropWindow) {
+ initCropWindow();
+ invalidate();
+ callOnCropWindowChanged(false);
+ }
+ }
+
+ /**
+ * Sets all initial values, but does not call initCropWindow to reset the views.
+ * Used once at the very start to initialize the attributes.
+ */
+ public void setInitialAttributeValues(CropImageOptions options) {
+
+ mCropWindowHandler.setInitialAttributeValues(options);
+
+ setCropShape(options.cropShape);
+
+ setSnapRadius(options.snapRadius);
+
+ setGuidelines(options.guidelines);
+
+ setFixedAspectRatio(options.fixAspectRatio);
+
+ setAspectRatioX(options.aspectRatioX);
+
+ setAspectRatioY(options.aspectRatioY);
+
+ setMultiTouchEnabled(options.multiTouchEnabled);
+
+ mTouchRadius = options.touchRadius;
+
+ mInitialCropWindowPaddingRatio = options.initialCropWindowPaddingRatio;
+
+ mBorderPaint = getNewPaintOrNull(options.borderLineThickness, options.borderLineColor);
+
+ mBorderCornerOffset = options.borderCornerOffset;
+ mBorderCornerLength = options.borderCornerLength;
+ mBorderCornerPaint =
+ getNewPaintOrNull(options.borderCornerThickness, options.borderCornerColor);
+
+ mGuidelinePaint = getNewPaintOrNull(options.guidelinesThickness, options.guidelinesColor);
+
+ mBackgroundPaint = getNewPaint(options.backgroundColor);
+ }
+
+ // region: Private methods
+
+ /**
+ * Set the initial crop window size and position. This is dependent on the size and position of
+ * the image being cropped.
+ */
+ private void initCropWindow() {
+
+ float leftLimit = Math.max(BitmapUtils.getRectLeft(mBoundsPoints), 0);
+ float topLimit = Math.max(BitmapUtils.getRectTop(mBoundsPoints), 0);
+ float rightLimit = Math.min(BitmapUtils.getRectRight(mBoundsPoints), getWidth());
+ float bottomLimit = Math.min(BitmapUtils.getRectBottom(mBoundsPoints), getHeight());
+
+ if (rightLimit <= leftLimit || bottomLimit <= topLimit) {
+ return;
+ }
+
+ RectF rect = new RectF();
+
+ // Tells the attribute functions the crop window has already been initialized
+ initializedCropWindow = true;
+
+ float horizontalPadding = mInitialCropWindowPaddingRatio * (rightLimit - leftLimit);
+ float verticalPadding = mInitialCropWindowPaddingRatio * (bottomLimit - topLimit);
+
+ if (mInitialCropWindowRect.width() > 0 && mInitialCropWindowRect.height() > 0) {
+ // Get crop window position relative to the displayed image.
+ rect.left =
+ leftLimit + mInitialCropWindowRect.left / mCropWindowHandler.getScaleFactorWidth();
+ rect.top = topLimit + mInitialCropWindowRect.top / mCropWindowHandler.getScaleFactorHeight();
+ rect.right =
+ rect.left + mInitialCropWindowRect.width() / mCropWindowHandler.getScaleFactorWidth();
+ rect.bottom =
+ rect.top + mInitialCropWindowRect.height() / mCropWindowHandler.getScaleFactorHeight();
+
+ // Correct for floating point errors. Crop rect boundaries should not exceed the source Bitmap
+ // bounds.
+ rect.left = Math.max(leftLimit, rect.left);
+ rect.top = Math.max(topLimit, rect.top);
+ rect.right = Math.min(rightLimit, rect.right);
+ rect.bottom = Math.min(bottomLimit, rect.bottom);
+
+ } else if (mFixAspectRatio && rightLimit > leftLimit && bottomLimit > topLimit) {
+
+ // If the image aspect ratio is wider than the crop aspect ratio,
+ // then the image height is the determining initial length. Else, vice-versa.
+ float bitmapAspectRatio = (rightLimit - leftLimit) / (bottomLimit - topLimit);
+ if (bitmapAspectRatio > mTargetAspectRatio) {
+
+ rect.top = topLimit + verticalPadding;
+ rect.bottom = bottomLimit - verticalPadding;
+
+ float centerX = getWidth() / 2f;
+
+ // dirty fix for wrong crop overlay aspect ratio when using fixed aspect ratio
+ mTargetAspectRatio = (float) mAspectRatioX / mAspectRatioY;
+
+ // Limits the aspect ratio to no less than 40 wide or 40 tall
+ float cropWidth =
+ Math.max(mCropWindowHandler.getMinCropWidth(), rect.height() * mTargetAspectRatio);
+
+ float halfCropWidth = cropWidth / 2f;
+ rect.left = centerX - halfCropWidth;
+ rect.right = centerX + halfCropWidth;
+
+ } else {
+
+ rect.left = leftLimit + horizontalPadding;
+ rect.right = rightLimit - horizontalPadding;
+
+ float centerY = getHeight() / 2f;
+
+ // Limits the aspect ratio to no less than 40 wide or 40 tall
+ float cropHeight =
+ Math.max(mCropWindowHandler.getMinCropHeight(), rect.width() / mTargetAspectRatio);
+
+ float halfCropHeight = cropHeight / 2f;
+ rect.top = centerY - halfCropHeight;
+ rect.bottom = centerY + halfCropHeight;
+ }
+ } else {
+ // Initialize crop window to have 10% padding w/ respect to image.
+ rect.left = leftLimit + horizontalPadding;
+ rect.top = topLimit + verticalPadding;
+ rect.right = rightLimit - horizontalPadding;
+ rect.bottom = bottomLimit - verticalPadding;
+ }
+
+ fixCropWindowRectByRules(rect);
+
+ mCropWindowHandler.setRect(rect);
+ }
+
+ /** Fix the given rect to fit into bitmap rect and follow min, max and aspect ratio rules. */
+ private void fixCropWindowRectByRules(RectF rect) {
+ if (rect.width() < mCropWindowHandler.getMinCropWidth()) {
+ float adj = (mCropWindowHandler.getMinCropWidth() - rect.width()) / 2;
+ rect.left -= adj;
+ rect.right += adj;
+ }
+ if (rect.height() < mCropWindowHandler.getMinCropHeight()) {
+ float adj = (mCropWindowHandler.getMinCropHeight() - rect.height()) / 2;
+ rect.top -= adj;
+ rect.bottom += adj;
+ }
+ if (rect.width() > mCropWindowHandler.getMaxCropWidth()) {
+ float adj = (rect.width() - mCropWindowHandler.getMaxCropWidth()) / 2;
+ rect.left += adj;
+ rect.right -= adj;
+ }
+ if (rect.height() > mCropWindowHandler.getMaxCropHeight()) {
+ float adj = (rect.height() - mCropWindowHandler.getMaxCropHeight()) / 2;
+ rect.top += adj;
+ rect.bottom -= adj;
+ }
+
+ calculateBounds(rect);
+ if (mCalcBounds.width() > 0 && mCalcBounds.height() > 0) {
+ float leftLimit = Math.max(mCalcBounds.left, 0);
+ float topLimit = Math.max(mCalcBounds.top, 0);
+ float rightLimit = Math.min(mCalcBounds.right, getWidth());
+ float bottomLimit = Math.min(mCalcBounds.bottom, getHeight());
+ if (rect.left < leftLimit) {
+ rect.left = leftLimit;
+ }
+ if (rect.top < topLimit) {
+ rect.top = topLimit;
+ }
+ if (rect.right > rightLimit) {
+ rect.right = rightLimit;
+ }
+ if (rect.bottom > bottomLimit) {
+ rect.bottom = bottomLimit;
+ }
+ }
+ if (mFixAspectRatio && Math.abs(rect.width() - rect.height() * mTargetAspectRatio) > 0.1) {
+ if (rect.width() > rect.height() * mTargetAspectRatio) {
+ float adj = Math.abs(rect.height() * mTargetAspectRatio - rect.width()) / 2;
+ rect.left += adj;
+ rect.right -= adj;
+ } else {
+ float adj = Math.abs(rect.width() / mTargetAspectRatio - rect.height()) / 2;
+ rect.top += adj;
+ rect.bottom -= adj;
+ }
+ }
+ }
+
+ /**
+ * Draw crop overview by drawing background over image not in the cripping area, then borders and
+ * guidelines.
+ */
+ @Override
+ protected void onDraw(Canvas canvas) {
+
+ super.onDraw(canvas);
+
+ // Draw translucent background for the cropped area.
+ drawBackground(canvas);
+
+ if (mCropWindowHandler.showGuidelines()) {
+ // Determines whether guidelines should be drawn or not
+ if (mGuidelines == CropImageView.Guidelines.ON) {
+ drawGuidelines(canvas);
+ } else if (mGuidelines == CropImageView.Guidelines.ON_TOUCH && mMoveHandler != null) {
+ // Draw only when resizing
+ drawGuidelines(canvas);
+ }
+ }
+
+ drawBorders(canvas);
+
+ drawCorners(canvas);
+ }
+
+ /** Draw shadow background over the image not including the crop area. */
+ private void drawBackground(Canvas canvas) {
+
+ RectF rect = mCropWindowHandler.getRect();
+
+ float left = Math.max(BitmapUtils.getRectLeft(mBoundsPoints), 0);
+ float top = Math.max(BitmapUtils.getRectTop(mBoundsPoints), 0);
+ float right = Math.min(BitmapUtils.getRectRight(mBoundsPoints), getWidth());
+ float bottom = Math.min(BitmapUtils.getRectBottom(mBoundsPoints), getHeight());
+
+ if (mCropShape == CropImageView.CropShape.RECTANGLE) {
+ if (!isNonStraightAngleRotated() || Build.VERSION.SDK_INT <= 17) {
+ canvas.drawRect(left, top, right, rect.top, mBackgroundPaint);
+ canvas.drawRect(left, rect.bottom, right, bottom, mBackgroundPaint);
+ canvas.drawRect(left, rect.top, rect.left, rect.bottom, mBackgroundPaint);
+ canvas.drawRect(rect.right, rect.top, right, rect.bottom, mBackgroundPaint);
+ } else {
+ mPath.reset();
+ mPath.moveTo(mBoundsPoints[0], mBoundsPoints[1]);
+ mPath.lineTo(mBoundsPoints[2], mBoundsPoints[3]);
+ mPath.lineTo(mBoundsPoints[4], mBoundsPoints[5]);
+ mPath.lineTo(mBoundsPoints[6], mBoundsPoints[7]);
+ mPath.close();
+
+ canvas.save();
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
+ canvas.clipOutPath(mPath);
+ } else {
+ canvas.clipPath(mPath, Region.Op.INTERSECT);
+ }
+ canvas.clipRect(rect, Region.Op.XOR);
+ canvas.drawRect(left, top, right, bottom, mBackgroundPaint);
+ canvas.restore();
+ }
+ } else {
+ mPath.reset();
+ if (Build.VERSION.SDK_INT <= 17 && mCropShape == CropImageView.CropShape.OVAL) {
+ mDrawRect.set(rect.left + 2, rect.top + 2, rect.right - 2, rect.bottom - 2);
+ } else {
+ mDrawRect.set(rect.left, rect.top, rect.right, rect.bottom);
+ }
+ mPath.addOval(mDrawRect, Path.Direction.CW);
+ canvas.save();
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
+ canvas.clipOutPath(mPath);
+ } else {
+ canvas.clipPath(mPath, Region.Op.XOR);
+ }
+ canvas.drawRect(left, top, right, bottom, mBackgroundPaint);
+ canvas.restore();
+ }
+ }
+
+ /**
+ * Draw 2 veritcal and 2 horizontal guidelines inside the cropping area to split it into 9 equal
+ * parts.
+ */
+ private void drawGuidelines(Canvas canvas) {
+ if (mGuidelinePaint != null) {
+ float sw = mBorderPaint != null ? mBorderPaint.getStrokeWidth() : 0;
+ RectF rect = mCropWindowHandler.getRect();
+ rect.inset(sw, sw);
+
+ float oneThirdCropWidth = rect.width() / 3;
+ float oneThirdCropHeight = rect.height() / 3;
+
+ if (mCropShape == CropImageView.CropShape.OVAL) {
+
+ float w = rect.width() / 2 - sw;
+ float h = rect.height() / 2 - sw;
+
+ // Draw vertical guidelines.
+ float x1 = rect.left + oneThirdCropWidth;
+ float x2 = rect.right - oneThirdCropWidth;
+ float yv = (float) (h * Math.sin(Math.acos((w - oneThirdCropWidth) / w)));
+ canvas.drawLine(x1, rect.top + h - yv, x1, rect.bottom - h + yv, mGuidelinePaint);
+ canvas.drawLine(x2, rect.top + h - yv, x2, rect.bottom - h + yv, mGuidelinePaint);
+
+ // Draw horizontal guidelines.
+ float y1 = rect.top + oneThirdCropHeight;
+ float y2 = rect.bottom - oneThirdCropHeight;
+ float xv = (float) (w * Math.cos(Math.asin((h - oneThirdCropHeight) / h)));
+ canvas.drawLine(rect.left + w - xv, y1, rect.right - w + xv, y1, mGuidelinePaint);
+ canvas.drawLine(rect.left + w - xv, y2, rect.right - w + xv, y2, mGuidelinePaint);
+ } else {
+
+ // Draw vertical guidelines.
+ float x1 = rect.left + oneThirdCropWidth;
+ float x2 = rect.right - oneThirdCropWidth;
+ canvas.drawLine(x1, rect.top, x1, rect.bottom, mGuidelinePaint);
+ canvas.drawLine(x2, rect.top, x2, rect.bottom, mGuidelinePaint);
+
+ // Draw horizontal guidelines.
+ float y1 = rect.top + oneThirdCropHeight;
+ float y2 = rect.bottom - oneThirdCropHeight;
+ canvas.drawLine(rect.left, y1, rect.right, y1, mGuidelinePaint);
+ canvas.drawLine(rect.left, y2, rect.right, y2, mGuidelinePaint);
+ }
+ }
+ }
+
+ /** Draw borders of the crop area. */
+ private void drawBorders(Canvas canvas) {
+ if (mBorderPaint != null) {
+ float w = mBorderPaint.getStrokeWidth();
+ RectF rect = mCropWindowHandler.getRect();
+ rect.inset(w / 2, w / 2);
+
+ if (mCropShape == CropImageView.CropShape.RECTANGLE) {
+ // Draw rectangle crop window border.
+ canvas.drawRect(rect, mBorderPaint);
+ } else {
+ // Draw circular crop window border
+ canvas.drawOval(rect, mBorderPaint);
+ }
+ }
+ }
+
+ /** Draw the corner of crop overlay. */
+ private void drawCorners(Canvas canvas) {
+ if (mBorderCornerPaint != null) {
+
+ float lineWidth = mBorderPaint != null ? mBorderPaint.getStrokeWidth() : 0;
+ float cornerWidth = mBorderCornerPaint.getStrokeWidth();
+
+ // for rectangle crop shape we allow the corners to be offset from the borders
+ float w =
+ cornerWidth / 2
+ + (mCropShape == CropImageView.CropShape.RECTANGLE ? mBorderCornerOffset : 0);
+
+ RectF rect = mCropWindowHandler.getRect();
+ rect.inset(w, w);
+
+ float cornerOffset = (cornerWidth - lineWidth) / 2;
+ float cornerExtension = cornerWidth / 2 + cornerOffset;
+
+ // Top left
+ canvas.drawLine(
+ rect.left - cornerOffset,
+ rect.top - cornerExtension,
+ rect.left - cornerOffset,
+ rect.top + mBorderCornerLength,
+ mBorderCornerPaint);
+ canvas.drawLine(
+ rect.left - cornerExtension,
+ rect.top - cornerOffset,
+ rect.left + mBorderCornerLength,
+ rect.top - cornerOffset,
+ mBorderCornerPaint);
+
+ // Top right
+ canvas.drawLine(
+ rect.right + cornerOffset,
+ rect.top - cornerExtension,
+ rect.right + cornerOffset,
+ rect.top + mBorderCornerLength,
+ mBorderCornerPaint);
+ canvas.drawLine(
+ rect.right + cornerExtension,
+ rect.top - cornerOffset,
+ rect.right - mBorderCornerLength,
+ rect.top - cornerOffset,
+ mBorderCornerPaint);
+
+ // Bottom left
+ canvas.drawLine(
+ rect.left - cornerOffset,
+ rect.bottom + cornerExtension,
+ rect.left - cornerOffset,
+ rect.bottom - mBorderCornerLength,
+ mBorderCornerPaint);
+ canvas.drawLine(
+ rect.left - cornerExtension,
+ rect.bottom + cornerOffset,
+ rect.left + mBorderCornerLength,
+ rect.bottom + cornerOffset,
+ mBorderCornerPaint);
+
+ // Bottom left
+ canvas.drawLine(
+ rect.right + cornerOffset,
+ rect.bottom + cornerExtension,
+ rect.right + cornerOffset,
+ rect.bottom - mBorderCornerLength,
+ mBorderCornerPaint);
+ canvas.drawLine(
+ rect.right + cornerExtension,
+ rect.bottom + cornerOffset,
+ rect.right - mBorderCornerLength,
+ rect.bottom + cornerOffset,
+ mBorderCornerPaint);
+ }
+ }
+
+ /** Creates the Paint object for drawing. */
+ private static Paint getNewPaint(int color) {
+ Paint paint = new Paint();
+ paint.setColor(color);
+ return paint;
+ }
+
+ /** Creates the Paint object for given thickness and color, if thickness < 0 return null. */
+ private static Paint getNewPaintOrNull(float thickness, int color) {
+ if (thickness > 0) {
+ Paint borderPaint = new Paint();
+ borderPaint.setColor(color);
+ borderPaint.setStrokeWidth(thickness);
+ borderPaint.setStyle(Paint.Style.STROKE);
+ borderPaint.setAntiAlias(true);
+ return borderPaint;
+ } else {
+ return null;
+ }
+ }
+
+ @Override
+ public boolean onTouchEvent(MotionEvent event) {
+ // If this View is not enabled, don't allow for touch interactions.
+ if (isEnabled()) {
+ if (mMultiTouchEnabled) {
+ mScaleDetector.onTouchEvent(event);
+ }
+
+ switch (event.getAction()) {
+ case MotionEvent.ACTION_DOWN:
+ onActionDown(event.getX(), event.getY());
+ return true;
+ case MotionEvent.ACTION_UP:
+ case MotionEvent.ACTION_CANCEL:
+ getParent().requestDisallowInterceptTouchEvent(false);
+ onActionUp();
+ return true;
+ case MotionEvent.ACTION_MOVE:
+ onActionMove(event.getX(), event.getY());
+ getParent().requestDisallowInterceptTouchEvent(true);
+ return true;
+ default:
+ return false;
+ }
+ } else {
+ return false;
+ }
+ }
+
+ /**
+ * On press down start crop window movment depending on the location of the press.
+ * if press is far from crop window then no move handler is returned (null).
+ */
+ private void onActionDown(float x, float y) {
+ mMoveHandler = mCropWindowHandler.getMoveHandler(x, y, mTouchRadius, mCropShape);
+ if (mMoveHandler != null) {
+ invalidate();
+ }
+ }
+
+ /** Clear move handler starting in {@link #onActionDown(float, float)} if exists. */
+ private void onActionUp() {
+ if (mMoveHandler != null) {
+ mMoveHandler = null;
+ callOnCropWindowChanged(false);
+ invalidate();
+ }
+ }
+
+ /**
+ * Handle move of crop window using the move handler created in {@link #onActionDown(float,
+ * float)}.
+ * The move handler will do the proper move/resize of the crop window.
+ */
+ private void onActionMove(float x, float y) {
+ if (mMoveHandler != null) {
+ float snapRadius = mSnapRadius;
+ RectF rect = mCropWindowHandler.getRect();
+
+ if (calculateBounds(rect)) {
+ snapRadius = 0;
+ }
+
+ mMoveHandler.move(
+ rect,
+ x,
+ y,
+ mCalcBounds,
+ mViewWidth,
+ mViewHeight,
+ snapRadius,
+ mFixAspectRatio,
+ mTargetAspectRatio);
+ mCropWindowHandler.setRect(rect);
+ callOnCropWindowChanged(true);
+ invalidate();
+ }
+ }
+
+ /**
+ * Calculate the bounding rectangle for current crop window, handle non-straight rotation angles.
+ *
+ * If the rotation angle is straight then the bounds rectangle is the bitmap rectangle, otherwsie
+ * we find the max rectangle that is within the image bounds starting from the crop window
+ * rectangle.
+ *
+ * @param rect the crop window rectangle to start finsing bounded rectangle from
+ * @return true - non straight rotation in place, false - otherwise.
+ */
+ private boolean calculateBounds(RectF rect) {
+
+ float left = BitmapUtils.getRectLeft(mBoundsPoints);
+ float top = BitmapUtils.getRectTop(mBoundsPoints);
+ float right = BitmapUtils.getRectRight(mBoundsPoints);
+ float bottom = BitmapUtils.getRectBottom(mBoundsPoints);
+
+ if (!isNonStraightAngleRotated()) {
+ mCalcBounds.set(left, top, right, bottom);
+ return false;
+ } else {
+ float x0 = mBoundsPoints[0];
+ float y0 = mBoundsPoints[1];
+ float x2 = mBoundsPoints[4];
+ float y2 = mBoundsPoints[5];
+ float x3 = mBoundsPoints[6];
+ float y3 = mBoundsPoints[7];
+
+ if (mBoundsPoints[7] < mBoundsPoints[1]) {
+ if (mBoundsPoints[1] < mBoundsPoints[3]) {
+ x0 = mBoundsPoints[6];
+ y0 = mBoundsPoints[7];
+ x2 = mBoundsPoints[2];
+ y2 = mBoundsPoints[3];
+ x3 = mBoundsPoints[4];
+ y3 = mBoundsPoints[5];
+ } else {
+ x0 = mBoundsPoints[4];
+ y0 = mBoundsPoints[5];
+ x2 = mBoundsPoints[0];
+ y2 = mBoundsPoints[1];
+ x3 = mBoundsPoints[2];
+ y3 = mBoundsPoints[3];
+ }
+ } else if (mBoundsPoints[1] > mBoundsPoints[3]) {
+ x0 = mBoundsPoints[2];
+ y0 = mBoundsPoints[3];
+ x2 = mBoundsPoints[6];
+ y2 = mBoundsPoints[7];
+ x3 = mBoundsPoints[0];
+ y3 = mBoundsPoints[1];
+ }
+
+ float a0 = (y3 - y0) / (x3 - x0);
+ float a1 = -1f / a0;
+ float b0 = y0 - a0 * x0;
+ float b1 = y0 - a1 * x0;
+ float b2 = y2 - a0 * x2;
+ float b3 = y2 - a1 * x2;
+
+ float c0 = (rect.centerY() - rect.top) / (rect.centerX() - rect.left);
+ float c1 = -c0;
+ float d0 = rect.top - c0 * rect.left;
+ float d1 = rect.top - c1 * rect.right;
+
+ left = Math.max(left, (d0 - b0) / (a0 - c0) < rect.right ? (d0 - b0) / (a0 - c0) : left);
+ left = Math.max(left, (d0 - b1) / (a1 - c0) < rect.right ? (d0 - b1) / (a1 - c0) : left);
+ left = Math.max(left, (d1 - b3) / (a1 - c1) < rect.right ? (d1 - b3) / (a1 - c1) : left);
+ right = Math.min(right, (d1 - b1) / (a1 - c1) > rect.left ? (d1 - b1) / (a1 - c1) : right);
+ right = Math.min(right, (d1 - b2) / (a0 - c1) > rect.left ? (d1 - b2) / (a0 - c1) : right);
+ right = Math.min(right, (d0 - b2) / (a0 - c0) > rect.left ? (d0 - b2) / (a0 - c0) : right);
+
+ top = Math.max(top, Math.max(a0 * left + b0, a1 * right + b1));
+ bottom = Math.min(bottom, Math.min(a1 * left + b3, a0 * right + b2));
+
+ mCalcBounds.left = left;
+ mCalcBounds.top = top;
+ mCalcBounds.right = right;
+ mCalcBounds.bottom = bottom;
+ return true;
+ }
+ }
+
+ /** Is the cropping image has been rotated by NOT 0,90,180 or 270 degrees. */
+ private boolean isNonStraightAngleRotated() {
+ return mBoundsPoints[0] != mBoundsPoints[6] && mBoundsPoints[1] != mBoundsPoints[7];
+ }
+
+ /** Invoke on crop change listener safe, don't let the app crash on exception. */
+ private void callOnCropWindowChanged(boolean inProgress) {
+ try {
+ if (mCropWindowChangeListener != null) {
+ mCropWindowChangeListener.onCropWindowChanged(inProgress);
+ }
+ } catch (Exception e) {
+ Log.e("AIC", "Exception in crop window changed", e);
+ }
+ }
+ // endregion
+
+ // region: Inner class: CropWindowChangeListener
+
+ /** Interface definition for a callback to be invoked when crop window rectangle is changing. */
+ public interface CropWindowChangeListener {
+
+ /**
+ * Called after a change in crop window rectangle.
+ *
+ * @param inProgress is the crop window change operation is still in progress by user touch
+ */
+ void onCropWindowChanged(boolean inProgress);
+ }
+ // endregion
+
+ // region: Inner class: ScaleListener
+
+ /** Handle scaling the rectangle based on two finger input */
+ private class ScaleListener extends ScaleGestureDetector.SimpleOnScaleGestureListener {
+
+ @Override
+ @TargetApi(Build.VERSION_CODES.HONEYCOMB)
+ public boolean onScale(ScaleGestureDetector detector) {
+ RectF rect = mCropWindowHandler.getRect();
+
+ float x = detector.getFocusX();
+ float y = detector.getFocusY();
+ float dY = detector.getCurrentSpanY() / 2;
+ float dX = detector.getCurrentSpanX() / 2;
+
+ float newTop = y - dY;
+ float newLeft = x - dX;
+ float newRight = x + dX;
+ float newBottom = y + dY;
+
+ if (newLeft < newRight
+ && newTop <= newBottom
+ && newLeft >= 0
+ && newRight <= mCropWindowHandler.getMaxCropWidth()
+ && newTop >= 0
+ && newBottom <= mCropWindowHandler.getMaxCropHeight()) {
+
+ rect.set(newLeft, newTop, newRight, newBottom);
+ mCropWindowHandler.setRect(rect);
+ invalidate();
+ }
+
+ return true;
+ }
+ }
+ // endregion
+}
diff --git a/cropper/src/main/java/com/theartofdev/edmodo/cropper/CropWindowHandler.java b/cropper/src/main/java/com/theartofdev/edmodo/cropper/CropWindowHandler.java
new file mode 100644
index 0000000..5595360
--- /dev/null
+++ b/cropper/src/main/java/com/theartofdev/edmodo/cropper/CropWindowHandler.java
@@ -0,0 +1,371 @@
+// "Therefore those skilled at the unorthodox
+// are infinite as heaven and earth,
+// inexhaustible as the great rivers.
+// When they come to an end,
+// they begin again,
+// like the days and months;
+// they die and are reborn,
+// like the four seasons."
+//
+// - Sun Tsu,
+// "The Art of War"
+
+package com.theartofdev.edmodo.cropper;
+
+import android.graphics.RectF;
+
+/** Handler from crop window stuff, moving and knowing possition. */
+final class CropWindowHandler {
+
+ // region: Fields and Consts
+
+ /** The 4 edges of the crop window defining its coordinates and size */
+ private final RectF mEdges = new RectF();
+
+ /**
+ * Rectangle used to return the edges rectangle without ability to change it and without creating
+ * new all the time.
+ */
+ private final RectF mGetEdges = new RectF();
+
+ /** Minimum width in pixels that the crop window can get. */
+ private float mMinCropWindowWidth;
+
+ /** Minimum height in pixels that the crop window can get. */
+ private float mMinCropWindowHeight;
+
+ /** Maximum width in pixels that the crop window can CURRENTLY get. */
+ private float mMaxCropWindowWidth;
+
+ /** Maximum height in pixels that the crop window can CURRENTLY get. */
+ private float mMaxCropWindowHeight;
+
+ /**
+ * Minimum width in pixels that the result of cropping an image can get, affects crop window width
+ * adjusted by width scale factor.
+ */
+ private float mMinCropResultWidth;
+
+ /**
+ * Minimum height in pixels that the result of cropping an image can get, affects crop window
+ * height adjusted by height scale factor.
+ */
+ private float mMinCropResultHeight;
+
+ /**
+ * Maximum width in pixels that the result of cropping an image can get, affects crop window width
+ * adjusted by width scale factor.
+ */
+ private float mMaxCropResultWidth;
+
+ /**
+ * Maximum height in pixels that the result of cropping an image can get, affects crop window
+ * height adjusted by height scale factor.
+ */
+ private float mMaxCropResultHeight;
+
+ /** The width scale factor of shown image and actual image */
+ private float mScaleFactorWidth = 1;
+
+ /** The height scale factor of shown image and actual image */
+ private float mScaleFactorHeight = 1;
+ // endregion
+
+ /** Get the left/top/right/bottom coordinates of the crop window. */
+ public RectF getRect() {
+ mGetEdges.set(mEdges);
+ return mGetEdges;
+ }
+
+ /** Minimum width in pixels that the crop window can get. */
+ public float getMinCropWidth() {
+ return Math.max(mMinCropWindowWidth, mMinCropResultWidth / mScaleFactorWidth);
+ }
+
+ /** Minimum height in pixels that the crop window can get. */
+ public float getMinCropHeight() {
+ return Math.max(mMinCropWindowHeight, mMinCropResultHeight / mScaleFactorHeight);
+ }
+
+ /** Maximum width in pixels that the crop window can get. */
+ public float getMaxCropWidth() {
+ return Math.min(mMaxCropWindowWidth, mMaxCropResultWidth / mScaleFactorWidth);
+ }
+
+ /** Maximum height in pixels that the crop window can get. */
+ public float getMaxCropHeight() {
+ return Math.min(mMaxCropWindowHeight, mMaxCropResultHeight / mScaleFactorHeight);
+ }
+
+ /** get the scale factor (on width) of the showen image to original image. */
+ public float getScaleFactorWidth() {
+ return mScaleFactorWidth;
+ }
+
+ /** get the scale factor (on height) of the showen image to original image. */
+ public float getScaleFactorHeight() {
+ return mScaleFactorHeight;
+ }
+
+ /**
+ * the min size the resulting cropping image is allowed to be, affects the cropping window limits
+ * (in pixels).
+ */
+ public void setMinCropResultSize(int minCropResultWidth, int minCropResultHeight) {
+ mMinCropResultWidth = minCropResultWidth;
+ mMinCropResultHeight = minCropResultHeight;
+ }
+
+ /**
+ * the max size the resulting cropping image is allowed to be, affects the cropping window limits
+ * (in pixels).
+ */
+ public void setMaxCropResultSize(int maxCropResultWidth, int maxCropResultHeight) {
+ mMaxCropResultWidth = maxCropResultWidth;
+ mMaxCropResultHeight = maxCropResultHeight;
+ }
+
+ /**
+ * set the max width/height and scale factor of the showen image to original image to scale the
+ * limits appropriately.
+ */
+ public void setCropWindowLimits(
+ float maxWidth, float maxHeight, float scaleFactorWidth, float scaleFactorHeight) {
+ mMaxCropWindowWidth = maxWidth;
+ mMaxCropWindowHeight = maxHeight;
+ mScaleFactorWidth = scaleFactorWidth;
+ mScaleFactorHeight = scaleFactorHeight;
+ }
+
+ /** Set the variables to be used during crop window handling. */
+ public void setInitialAttributeValues(CropImageOptions options) {
+ mMinCropWindowWidth = options.minCropWindowWidth;
+ mMinCropWindowHeight = options.minCropWindowHeight;
+ mMinCropResultWidth = options.minCropResultWidth;
+ mMinCropResultHeight = options.minCropResultHeight;
+ mMaxCropResultWidth = options.maxCropResultWidth;
+ mMaxCropResultHeight = options.maxCropResultHeight;
+ }
+
+ /** Set the left/top/right/bottom coordinates of the crop window. */
+ public void setRect(RectF rect) {
+ mEdges.set(rect);
+ }
+
+ /**
+ * Indicates whether the crop window is small enough that the guidelines should be shown. Public
+ * because this function is also used to determine if the center handle should be focused.
+ *
+ * @return boolean Whether the guidelines should be shown or not
+ */
+ public boolean showGuidelines() {
+ return !(mEdges.width() < 100 || mEdges.height() < 100);
+ }
+
+ /**
+ * Determines which, if any, of the handles are pressed given the touch coordinates, the bounding
+ * box, and the touch radius.
+ *
+ * @param x the x-coordinate of the touch point
+ * @param y the y-coordinate of the touch point
+ * @param targetRadius the target radius in pixels
+ * @return the Handle that was pressed; null if no Handle was pressed
+ */
+ public CropWindowMoveHandler getMoveHandler(
+ float x, float y, float targetRadius, CropImageView.CropShape cropShape) {
+ CropWindowMoveHandler.Type type =
+ cropShape == CropImageView.CropShape.OVAL
+ ? getOvalPressedMoveType(x, y)
+ : getRectanglePressedMoveType(x, y, targetRadius);
+ return type != null ? new CropWindowMoveHandler(type, this, x, y) : null;
+ }
+
+ // region: Private methods
+
+ /**
+ * Determines which, if any, of the handles are pressed given the touch coordinates, the bounding
+ * box, and the touch radius.
+ *
+ * @param x the x-coordinate of the touch point
+ * @param y the y-coordinate of the touch point
+ * @param targetRadius the target radius in pixels
+ * @return the Handle that was pressed; null if no Handle was pressed
+ */
+ private CropWindowMoveHandler.Type getRectanglePressedMoveType(
+ float x, float y, float targetRadius) {
+ CropWindowMoveHandler.Type moveType = null;
+
+ // Note: corner-handles take precedence, then side-handles, then center.
+ if (CropWindowHandler.isInCornerTargetZone(x, y, mEdges.left, mEdges.top, targetRadius)) {
+ moveType = CropWindowMoveHandler.Type.TOP_LEFT;
+ } else if (CropWindowHandler.isInCornerTargetZone(
+ x, y, mEdges.right, mEdges.top, targetRadius)) {
+ moveType = CropWindowMoveHandler.Type.TOP_RIGHT;
+ } else if (CropWindowHandler.isInCornerTargetZone(
+ x, y, mEdges.left, mEdges.bottom, targetRadius)) {
+ moveType = CropWindowMoveHandler.Type.BOTTOM_LEFT;
+ } else if (CropWindowHandler.isInCornerTargetZone(
+ x, y, mEdges.right, mEdges.bottom, targetRadius)) {
+ moveType = CropWindowMoveHandler.Type.BOTTOM_RIGHT;
+ } else if (CropWindowHandler.isInCenterTargetZone(
+ x, y, mEdges.left, mEdges.top, mEdges.right, mEdges.bottom)
+ && focusCenter()) {
+ moveType = CropWindowMoveHandler.Type.CENTER;
+ } else if (CropWindowHandler.isInHorizontalTargetZone(
+ x, y, mEdges.left, mEdges.right, mEdges.top, targetRadius)) {
+ moveType = CropWindowMoveHandler.Type.TOP;
+ } else if (CropWindowHandler.isInHorizontalTargetZone(
+ x, y, mEdges.left, mEdges.right, mEdges.bottom, targetRadius)) {
+ moveType = CropWindowMoveHandler.Type.BOTTOM;
+ } else if (CropWindowHandler.isInVerticalTargetZone(
+ x, y, mEdges.left, mEdges.top, mEdges.bottom, targetRadius)) {
+ moveType = CropWindowMoveHandler.Type.LEFT;
+ } else if (CropWindowHandler.isInVerticalTargetZone(
+ x, y, mEdges.right, mEdges.top, mEdges.bottom, targetRadius)) {
+ moveType = CropWindowMoveHandler.Type.RIGHT;
+ } else if (CropWindowHandler.isInCenterTargetZone(
+ x, y, mEdges.left, mEdges.top, mEdges.right, mEdges.bottom)
+ && !focusCenter()) {
+ moveType = CropWindowMoveHandler.Type.CENTER;
+ }
+
+ return moveType;
+ }
+
+ /**
+ * Determines which, if any, of the handles are pressed given the touch coordinates, the bounding
+ * box/oval, and the touch radius.
+ *
+ * @param x the x-coordinate of the touch point
+ * @param y the y-coordinate of the touch point
+ * @return the Handle that was pressed; null if no Handle was pressed
+ */
+ private CropWindowMoveHandler.Type getOvalPressedMoveType(float x, float y) {
+
+ /*
+ Use a 6x6 grid system divided into 9 "handles", with the center the biggest region. While
+ this is not perfect, it's a good quick-to-ship approach.
+
+ TL T T T T TR
+ L C C C C R
+ L C C C C R
+ L C C C C R
+ L C C C C R
+ BL B B B B BR
+ */
+
+ float cellLength = mEdges.width() / 6;
+ float leftCenter = mEdges.left + cellLength;
+ float rightCenter = mEdges.left + (5 * cellLength);
+
+ float cellHeight = mEdges.height() / 6;
+ float topCenter = mEdges.top + cellHeight;
+ float bottomCenter = mEdges.top + 5 * cellHeight;
+
+ CropWindowMoveHandler.Type moveType;
+ if (x < leftCenter) {
+ if (y < topCenter) {
+ moveType = CropWindowMoveHandler.Type.TOP_LEFT;
+ } else if (y < bottomCenter) {
+ moveType = CropWindowMoveHandler.Type.LEFT;
+ } else {
+ moveType = CropWindowMoveHandler.Type.BOTTOM_LEFT;
+ }
+ } else if (x < rightCenter) {
+ if (y < topCenter) {
+ moveType = CropWindowMoveHandler.Type.TOP;
+ } else if (y < bottomCenter) {
+ moveType = CropWindowMoveHandler.Type.CENTER;
+ } else {
+ moveType = CropWindowMoveHandler.Type.BOTTOM;
+ }
+ } else {
+ if (y < topCenter) {
+ moveType = CropWindowMoveHandler.Type.TOP_RIGHT;
+ } else if (y < bottomCenter) {
+ moveType = CropWindowMoveHandler.Type.RIGHT;
+ } else {
+ moveType = CropWindowMoveHandler.Type.BOTTOM_RIGHT;
+ }
+ }
+
+ return moveType;
+ }
+
+ /**
+ * Determines if the specified coordinate is in the target touch zone for a corner handle.
+ *
+ * @param x the x-coordinate of the touch point
+ * @param y the y-coordinate of the touch point
+ * @param handleX the x-coordinate of the corner handle
+ * @param handleY the y-coordinate of the corner handle
+ * @param targetRadius the target radius in pixels
+ * @return true if the touch point is in the target touch zone; false otherwise
+ */
+ private static boolean isInCornerTargetZone(
+ float x, float y, float handleX, float handleY, float targetRadius) {
+ return Math.abs(x - handleX) <= targetRadius && Math.abs(y - handleY) <= targetRadius;
+ }
+
+ /**
+ * Determines if the specified coordinate is in the target touch zone for a horizontal bar handle.
+ *
+ * @param x the x-coordinate of the touch point
+ * @param y the y-coordinate of the touch point
+ * @param handleXStart the left x-coordinate of the horizontal bar handle
+ * @param handleXEnd the right x-coordinate of the horizontal bar handle
+ * @param handleY the y-coordinate of the horizontal bar handle
+ * @param targetRadius the target radius in pixels
+ * @return true if the touch point is in the target touch zone; false otherwise
+ */
+ private static boolean isInHorizontalTargetZone(
+ float x, float y, float handleXStart, float handleXEnd, float handleY, float targetRadius) {
+ return x > handleXStart && x < handleXEnd && Math.abs(y - handleY) <= targetRadius;
+ }
+
+ /**
+ * Determines if the specified coordinate is in the target touch zone for a vertical bar handle.
+ *
+ * @param x the x-coordinate of the touch point
+ * @param y the y-coordinate of the touch point
+ * @param handleX the x-coordinate of the vertical bar handle
+ * @param handleYStart the top y-coordinate of the vertical bar handle
+ * @param handleYEnd the bottom y-coordinate of the vertical bar handle
+ * @param targetRadius the target radius in pixels
+ * @return true if the touch point is in the target touch zone; false otherwise
+ */
+ private static boolean isInVerticalTargetZone(
+ float x, float y, float handleX, float handleYStart, float handleYEnd, float targetRadius) {
+ return Math.abs(x - handleX) <= targetRadius && y > handleYStart && y < handleYEnd;
+ }
+
+ /**
+ * Determines if the specified coordinate falls anywhere inside the given bounds.
+ *
+ * @param x the x-coordinate of the touch point
+ * @param y the y-coordinate of the touch point
+ * @param left the x-coordinate of the left bound
+ * @param top the y-coordinate of the top bound
+ * @param right the x-coordinate of the right bound
+ * @param bottom the y-coordinate of the bottom bound
+ * @return true if the touch point is inside the bounding rectangle; false otherwise
+ */
+ private static boolean isInCenterTargetZone(
+ float x, float y, float left, float top, float right, float bottom) {
+ return x > left && x < right && y > top && y < bottom;
+ }
+
+ /**
+ * Determines if the cropper should focus on the center handle or the side handles. If it is a
+ * small image, focus on the center handle so the user can move it. If it is a large image, focus
+ * on the side handles so user can grab them. Corresponds to the appearance of the
+ * RuleOfThirdsGuidelines.
+ *
+ * @return true if it is small enough such that it should focus on the center; less than
+ * show_guidelines limit
+ */
+ private boolean focusCenter() {
+ return !showGuidelines();
+ }
+ // endregion
+}
diff --git a/cropper/src/main/java/com/theartofdev/edmodo/cropper/CropWindowMoveHandler.java b/cropper/src/main/java/com/theartofdev/edmodo/cropper/CropWindowMoveHandler.java
new file mode 100644
index 0000000..f14b77b
--- /dev/null
+++ b/cropper/src/main/java/com/theartofdev/edmodo/cropper/CropWindowMoveHandler.java
@@ -0,0 +1,766 @@
+// "Therefore those skilled at the unorthodox
+// are infinite as heaven and earth,
+// inexhaustible as the great rivers.
+// When they come to an end,
+// they begin again,
+// like the days and months;
+// they die and are reborn,
+// like the four seasons."
+//
+// - Sun Tsu,
+// "The Art of War"
+
+package com.theartofdev.edmodo.cropper;
+
+import android.graphics.Matrix;
+import android.graphics.PointF;
+import android.graphics.RectF;
+
+/**
+ * Handler to update crop window edges by the move type - Horizontal, Vertical, Corner or Center.
+ *
+ */
+final class CropWindowMoveHandler {
+
+ // region: Fields and Consts
+
+ /** Matrix used for rectangle rotation handling */
+ private static final Matrix MATRIX = new Matrix();
+
+ /** Minimum width in pixels that the crop window can get. */
+ private final float mMinCropWidth;
+
+ /** Minimum width in pixels that the crop window can get. */
+ private final float mMinCropHeight;
+
+ /** Maximum height in pixels that the crop window can get. */
+ private final float mMaxCropWidth;
+
+ /** Maximum height in pixels that the crop window can get. */
+ private final float mMaxCropHeight;
+
+ /** The type of crop window move that is handled. */
+ private final Type mType;
+
+ /**
+ * Holds the x and y offset between the exact touch location and the exact handle location that is
+ * activated. There may be an offset because we allow for some leeway (specified by mHandleRadius)
+ * in activating a handle. However, we want to maintain these offset values while the handle is
+ * being dragged so that the handle doesn't jump.
+ */
+ private final PointF mTouchOffset = new PointF();
+ // endregion
+
+ /**
+ * @param edgeMoveType the type of move this handler is executing
+ * @param horizontalEdge the primary edge associated with this handle; may be null
+ * @param verticalEdge the secondary edge associated with this handle; may be null
+ * @param cropWindowHandler main crop window handle to get and update the crop window edges
+ * @param touchX the location of the initial toch possition to measure move distance
+ * @param touchY the location of the initial toch possition to measure move distance
+ */
+ public CropWindowMoveHandler(
+ Type type, CropWindowHandler cropWindowHandler, float touchX, float touchY) {
+ mType = type;
+ mMinCropWidth = cropWindowHandler.getMinCropWidth();
+ mMinCropHeight = cropWindowHandler.getMinCropHeight();
+ mMaxCropWidth = cropWindowHandler.getMaxCropWidth();
+ mMaxCropHeight = cropWindowHandler.getMaxCropHeight();
+ calculateTouchOffset(cropWindowHandler.getRect(), touchX, touchY);
+ }
+
+ /**
+ * Updates the crop window by change in the toch location.
+ * Move type handled by this instance, as initialized in creation, affects how the change in toch
+ * location changes the crop window position and size.
+ * After the crop window position/size is changed by toch move it may result in values that
+ * vialate contraints: outside the bounds of the shown bitmap, smaller/larger than min/max size or
+ * missmatch in aspect ratio. So a series of fixes is executed on "secondary" edges to adjust it
+ * by the "primary" edge movement.
+ * Primary is the edge directly affected by move type, secondary is the other edge.
+ * The crop window is changed by directly setting the Edge coordinates.
+ *
+ * @param x the new x-coordinate of this handle
+ * @param y the new y-coordinate of this handle
+ * @param bounds the bounding rectangle of the image
+ * @param viewWidth The bounding image view width used to know the crop overlay is at view edges.
+ * @param viewHeight The bounding image view height used to know the crop overlay is at view
+ * edges.
+ * @param parentView the parent View containing the image
+ * @param snapMargin the maximum distance (in pixels) at which the crop window should snap to the
+ * image
+ * @param fixedAspectRatio is the aspect ration fixed and 'targetAspectRatio' should be used
+ * @param aspectRatio the aspect ratio to maintain
+ */
+ public void move(
+ RectF rect,
+ float x,
+ float y,
+ RectF bounds,
+ int viewWidth,
+ int viewHeight,
+ float snapMargin,
+ boolean fixedAspectRatio,
+ float aspectRatio) {
+
+ // Adjust the coordinates for the finger position's offset (i.e. the
+ // distance from the initial touch to the precise handle location).
+ // We want to maintain the initial touch's distance to the pressed
+ // handle so that the crop window size does not "jump".
+ float adjX = x + mTouchOffset.x;
+ float adjY = y + mTouchOffset.y;
+
+ if (mType == Type.CENTER) {
+ moveCenter(rect, adjX, adjY, bounds, viewWidth, viewHeight, snapMargin);
+ } else {
+ if (fixedAspectRatio) {
+ moveSizeWithFixedAspectRatio(
+ rect, adjX, adjY, bounds, viewWidth, viewHeight, snapMargin, aspectRatio);
+ } else {
+ moveSizeWithFreeAspectRatio(rect, adjX, adjY, bounds, viewWidth, viewHeight, snapMargin);
+ }
+ }
+ }
+
+ // region: Private methods
+
+ /**
+ * Calculates the offset of the touch point from the precise location of the specified handle.
+ * Save these values in a member variable since we want to maintain this offset as we drag the
+ * handle.
+ */
+ private void calculateTouchOffset(RectF rect, float touchX, float touchY) {
+
+ float touchOffsetX = 0;
+ float touchOffsetY = 0;
+
+ // Calculate the offset from the appropriate handle.
+ switch (mType) {
+ case TOP_LEFT:
+ touchOffsetX = rect.left - touchX;
+ touchOffsetY = rect.top - touchY;
+ break;
+ case TOP_RIGHT:
+ touchOffsetX = rect.right - touchX;
+ touchOffsetY = rect.top - touchY;
+ break;
+ case BOTTOM_LEFT:
+ touchOffsetX = rect.left - touchX;
+ touchOffsetY = rect.bottom - touchY;
+ break;
+ case BOTTOM_RIGHT:
+ touchOffsetX = rect.right - touchX;
+ touchOffsetY = rect.bottom - touchY;
+ break;
+ case LEFT:
+ touchOffsetX = rect.left - touchX;
+ touchOffsetY = 0;
+ break;
+ case TOP:
+ touchOffsetX = 0;
+ touchOffsetY = rect.top - touchY;
+ break;
+ case RIGHT:
+ touchOffsetX = rect.right - touchX;
+ touchOffsetY = 0;
+ break;
+ case BOTTOM:
+ touchOffsetX = 0;
+ touchOffsetY = rect.bottom - touchY;
+ break;
+ case CENTER:
+ touchOffsetX = rect.centerX() - touchX;
+ touchOffsetY = rect.centerY() - touchY;
+ break;
+ default:
+ break;
+ }
+
+ mTouchOffset.x = touchOffsetX;
+ mTouchOffset.y = touchOffsetY;
+ }
+
+ /** Center move only changes the position of the crop window without changing the size. */
+ private void moveCenter(
+ RectF rect, float x, float y, RectF bounds, int viewWidth, int viewHeight, float snapRadius) {
+ float dx = x - rect.centerX();
+ float dy = y - rect.centerY();
+ if (rect.left + dx < 0
+ || rect.right + dx > viewWidth
+ || rect.left + dx < bounds.left
+ || rect.right + dx > bounds.right) {
+ dx /= 1.05f;
+ mTouchOffset.x -= dx / 2;
+ }
+ if (rect.top + dy < 0
+ || rect.bottom + dy > viewHeight
+ || rect.top + dy < bounds.top
+ || rect.bottom + dy > bounds.bottom) {
+ dy /= 1.05f;
+ mTouchOffset.y -= dy / 2;
+ }
+ rect.offset(dx, dy);
+ snapEdgesToBounds(rect, bounds, snapRadius);
+ }
+
+ /**
+ * Change the size of the crop window on the required edge (or edges for corner size move) without
+ * affecting "secondary" edges.
+ * Only the primary edge(s) are fixed to stay within limits.
+ */
+ private void moveSizeWithFreeAspectRatio(
+ RectF rect, float x, float y, RectF bounds, int viewWidth, int viewHeight, float snapMargin) {
+ switch (mType) {
+ case TOP_LEFT:
+ adjustTop(rect, y, bounds, snapMargin, 0, false, false);
+ adjustLeft(rect, x, bounds, snapMargin, 0, false, false);
+ break;
+ case TOP_RIGHT:
+ adjustTop(rect, y, bounds, snapMargin, 0, false, false);
+ adjustRight(rect, x, bounds, viewWidth, snapMargin, 0, false, false);
+ break;
+ case BOTTOM_LEFT:
+ adjustBottom(rect, y, bounds, viewHeight, snapMargin, 0, false, false);
+ adjustLeft(rect, x, bounds, snapMargin, 0, false, false);
+ break;
+ case BOTTOM_RIGHT:
+ adjustBottom(rect, y, bounds, viewHeight, snapMargin, 0, false, false);
+ adjustRight(rect, x, bounds, viewWidth, snapMargin, 0, false, false);
+ break;
+ case LEFT:
+ adjustLeft(rect, x, bounds, snapMargin, 0, false, false);
+ break;
+ case TOP:
+ adjustTop(rect, y, bounds, snapMargin, 0, false, false);
+ break;
+ case RIGHT:
+ adjustRight(rect, x, bounds, viewWidth, snapMargin, 0, false, false);
+ break;
+ case BOTTOM:
+ adjustBottom(rect, y, bounds, viewHeight, snapMargin, 0, false, false);
+ break;
+ default:
+ break;
+ }
+ }
+
+ /**
+ * Change the size of the crop window on the required "primary" edge WITH affect to relevant
+ * "secondary" edge via aspect ratio.
+ * Example: change in the left edge (primary) will affect top and bottom edges (secondary) to
+ * preserve the given aspect ratio.
+ */
+ private void moveSizeWithFixedAspectRatio(
+ RectF rect,
+ float x,
+ float y,
+ RectF bounds,
+ int viewWidth,
+ int viewHeight,
+ float snapMargin,
+ float aspectRatio) {
+ switch (mType) {
+ case TOP_LEFT:
+ if (calculateAspectRatio(x, y, rect.right, rect.bottom) < aspectRatio) {
+ adjustTop(rect, y, bounds, snapMargin, aspectRatio, true, false);
+ adjustLeftByAspectRatio(rect, aspectRatio);
+ } else {
+ adjustLeft(rect, x, bounds, snapMargin, aspectRatio, true, false);
+ adjustTopByAspectRatio(rect, aspectRatio);
+ }
+ break;
+ case TOP_RIGHT:
+ if (calculateAspectRatio(rect.left, y, x, rect.bottom) < aspectRatio) {
+ adjustTop(rect, y, bounds, snapMargin, aspectRatio, false, true);
+ adjustRightByAspectRatio(rect, aspectRatio);
+ } else {
+ adjustRight(rect, x, bounds, viewWidth, snapMargin, aspectRatio, true, false);
+ adjustTopByAspectRatio(rect, aspectRatio);
+ }
+ break;
+ case BOTTOM_LEFT:
+ if (calculateAspectRatio(x, rect.top, rect.right, y) < aspectRatio) {
+ adjustBottom(rect, y, bounds, viewHeight, snapMargin, aspectRatio, true, false);
+ adjustLeftByAspectRatio(rect, aspectRatio);
+ } else {
+ adjustLeft(rect, x, bounds, snapMargin, aspectRatio, false, true);
+ adjustBottomByAspectRatio(rect, aspectRatio);
+ }
+ break;
+ case BOTTOM_RIGHT:
+ if (calculateAspectRatio(rect.left, rect.top, x, y) < aspectRatio) {
+ adjustBottom(rect, y, bounds, viewHeight, snapMargin, aspectRatio, false, true);
+ adjustRightByAspectRatio(rect, aspectRatio);
+ } else {
+ adjustRight(rect, x, bounds, viewWidth, snapMargin, aspectRatio, false, true);
+ adjustBottomByAspectRatio(rect, aspectRatio);
+ }
+ break;
+ case LEFT:
+ adjustLeft(rect, x, bounds, snapMargin, aspectRatio, true, true);
+ adjustTopBottomByAspectRatio(rect, bounds, aspectRatio);
+ break;
+ case TOP:
+ adjustTop(rect, y, bounds, snapMargin, aspectRatio, true, true);
+ adjustLeftRightByAspectRatio(rect, bounds, aspectRatio);
+ break;
+ case RIGHT:
+ adjustRight(rect, x, bounds, viewWidth, snapMargin, aspectRatio, true, true);
+ adjustTopBottomByAspectRatio(rect, bounds, aspectRatio);
+ break;
+ case BOTTOM:
+ adjustBottom(rect, y, bounds, viewHeight, snapMargin, aspectRatio, true, true);
+ adjustLeftRightByAspectRatio(rect, bounds, aspectRatio);
+ break;
+ default:
+ break;
+ }
+ }
+
+ /** Check if edges have gone out of bounds (including snap margin), and fix if needed. */
+ private void snapEdgesToBounds(RectF edges, RectF bounds, float margin) {
+ if (edges.left < bounds.left + margin) {
+ edges.offset(bounds.left - edges.left, 0);
+ }
+ if (edges.top < bounds.top + margin) {
+ edges.offset(0, bounds.top - edges.top);
+ }
+ if (edges.right > bounds.right - margin) {
+ edges.offset(bounds.right - edges.right, 0);
+ }
+ if (edges.bottom > bounds.bottom - margin) {
+ edges.offset(0, bounds.bottom - edges.bottom);
+ }
+ }
+
+ /**
+ * Get the resulting x-position of the left edge of the crop window given the handle's position
+ * and the image's bounding box and snap radius.
+ *
+ * @param left the position that the left edge is dragged to
+ * @param bounds the bounding box of the image that is being cropped
+ * @param snapMargin the snap distance to the image edge (in pixels)
+ */
+ private void adjustLeft(
+ RectF rect,
+ float left,
+ RectF bounds,
+ float snapMargin,
+ float aspectRatio,
+ boolean topMoves,
+ boolean bottomMoves) {
+
+ float newLeft = left;
+
+ if (newLeft < 0) {
+ newLeft /= 1.05f;
+ mTouchOffset.x -= newLeft / 1.1f;
+ }
+
+ if (newLeft < bounds.left) {
+ mTouchOffset.x -= (newLeft - bounds.left) / 2f;
+ }
+
+ if (newLeft - bounds.left < snapMargin) {
+ newLeft = bounds.left;
+ }
+
+ // Checks if the window is too small horizontally
+ if (rect.right - newLeft < mMinCropWidth) {
+ newLeft = rect.right - mMinCropWidth;
+ }
+
+ // Checks if the window is too large horizontally
+ if (rect.right - newLeft > mMaxCropWidth) {
+ newLeft = rect.right - mMaxCropWidth;
+ }
+
+ if (newLeft - bounds.left < snapMargin) {
+ newLeft = bounds.left;
+ }
+
+ // check vertical bounds if aspect ratio is in play
+ if (aspectRatio > 0) {
+ float newHeight = (rect.right - newLeft) / aspectRatio;
+
+ // Checks if the window is too small vertically
+ if (newHeight < mMinCropHeight) {
+ newLeft = Math.max(bounds.left, rect.right - mMinCropHeight * aspectRatio);
+ newHeight = (rect.right - newLeft) / aspectRatio;
+ }
+
+ // Checks if the window is too large vertically
+ if (newHeight > mMaxCropHeight) {
+ newLeft = Math.max(bounds.left, rect.right - mMaxCropHeight * aspectRatio);
+ newHeight = (rect.right - newLeft) / aspectRatio;
+ }
+
+ // if top AND bottom edge moves by aspect ratio check that it is within full height bounds
+ if (topMoves && bottomMoves) {
+ newLeft =
+ Math.max(newLeft, Math.max(bounds.left, rect.right - bounds.height() * aspectRatio));
+ } else {
+ // if top edge moves by aspect ratio check that it is within bounds
+ if (topMoves && rect.bottom - newHeight < bounds.top) {
+ newLeft = Math.max(bounds.left, rect.right - (rect.bottom - bounds.top) * aspectRatio);
+ newHeight = (rect.right - newLeft) / aspectRatio;
+ }
+
+ // if bottom edge moves by aspect ratio check that it is within bounds
+ if (bottomMoves && rect.top + newHeight > bounds.bottom) {
+ newLeft =
+ Math.max(
+ newLeft,
+ Math.max(bounds.left, rect.right - (bounds.bottom - rect.top) * aspectRatio));
+ }
+ }
+ }
+
+ rect.left = newLeft;
+ }
+
+ /**
+ * Get the resulting x-position of the right edge of the crop window given the handle's position
+ * and the image's bounding box and snap radius.
+ *
+ * @param right the position that the right edge is dragged to
+ * @param bounds the bounding box of the image that is being cropped
+ * @param viewWidth
+ * @param snapMargin the snap distance to the image edge (in pixels)
+ */
+ private void adjustRight(
+ RectF rect,
+ float right,
+ RectF bounds,
+ int viewWidth,
+ float snapMargin,
+ float aspectRatio,
+ boolean topMoves,
+ boolean bottomMoves) {
+
+ float newRight = right;
+
+ if (newRight > viewWidth) {
+ newRight = viewWidth + (newRight - viewWidth) / 1.05f;
+ mTouchOffset.x -= (newRight - viewWidth) / 1.1f;
+ }
+
+ if (newRight > bounds.right) {
+ mTouchOffset.x -= (newRight - bounds.right) / 2f;
+ }
+
+ // If close to the edge
+ if (bounds.right - newRight < snapMargin) {
+ newRight = bounds.right;
+ }
+
+ // Checks if the window is too small horizontally
+ if (newRight - rect.left < mMinCropWidth) {
+ newRight = rect.left + mMinCropWidth;
+ }
+
+ // Checks if the window is too large horizontally
+ if (newRight - rect.left > mMaxCropWidth) {
+ newRight = rect.left + mMaxCropWidth;
+ }
+
+ // If close to the edge
+ if (bounds.right - newRight < snapMargin) {
+ newRight = bounds.right;
+ }
+
+ // check vertical bounds if aspect ratio is in play
+ if (aspectRatio > 0) {
+ float newHeight = (newRight - rect.left) / aspectRatio;
+
+ // Checks if the window is too small vertically
+ if (newHeight < mMinCropHeight) {
+ newRight = Math.min(bounds.right, rect.left + mMinCropHeight * aspectRatio);
+ newHeight = (newRight - rect.left) / aspectRatio;
+ }
+
+ // Checks if the window is too large vertically
+ if (newHeight > mMaxCropHeight) {
+ newRight = Math.min(bounds.right, rect.left + mMaxCropHeight * aspectRatio);
+ newHeight = (newRight - rect.left) / aspectRatio;
+ }
+
+ // if top AND bottom edge moves by aspect ratio check that it is within full height bounds
+ if (topMoves && bottomMoves) {
+ newRight =
+ Math.min(newRight, Math.min(bounds.right, rect.left + bounds.height() * aspectRatio));
+ } else {
+ // if top edge moves by aspect ratio check that it is within bounds
+ if (topMoves && rect.bottom - newHeight < bounds.top) {
+ newRight = Math.min(bounds.right, rect.left + (rect.bottom - bounds.top) * aspectRatio);
+ newHeight = (newRight - rect.left) / aspectRatio;
+ }
+
+ // if bottom edge moves by aspect ratio check that it is within bounds
+ if (bottomMoves && rect.top + newHeight > bounds.bottom) {
+ newRight =
+ Math.min(
+ newRight,
+ Math.min(bounds.right, rect.left + (bounds.bottom - rect.top) * aspectRatio));
+ }
+ }
+ }
+
+ rect.right = newRight;
+ }
+
+ /**
+ * Get the resulting y-position of the top edge of the crop window given the handle's position and
+ * the image's bounding box and snap radius.
+ *
+ * @param top the x-position that the top edge is dragged to
+ * @param bounds the bounding box of the image that is being cropped
+ * @param snapMargin the snap distance to the image edge (in pixels)
+ */
+ private void adjustTop(
+ RectF rect,
+ float top,
+ RectF bounds,
+ float snapMargin,
+ float aspectRatio,
+ boolean leftMoves,
+ boolean rightMoves) {
+
+ float newTop = top;
+
+ if (newTop < 0) {
+ newTop /= 1.05f;
+ mTouchOffset.y -= newTop / 1.1f;
+ }
+
+ if (newTop < bounds.top) {
+ mTouchOffset.y -= (newTop - bounds.top) / 2f;
+ }
+
+ if (newTop - bounds.top < snapMargin) {
+ newTop = bounds.top;
+ }
+
+ // Checks if the window is too small vertically
+ if (rect.bottom - newTop < mMinCropHeight) {
+ newTop = rect.bottom - mMinCropHeight;
+ }
+
+ // Checks if the window is too large vertically
+ if (rect.bottom - newTop > mMaxCropHeight) {
+ newTop = rect.bottom - mMaxCropHeight;
+ }
+
+ if (newTop - bounds.top < snapMargin) {
+ newTop = bounds.top;
+ }
+
+ // check horizontal bounds if aspect ratio is in play
+ if (aspectRatio > 0) {
+ float newWidth = (rect.bottom - newTop) * aspectRatio;
+
+ // Checks if the crop window is too small horizontally due to aspect ratio adjustment
+ if (newWidth < mMinCropWidth) {
+ newTop = Math.max(bounds.top, rect.bottom - (mMinCropWidth / aspectRatio));
+ newWidth = (rect.bottom - newTop) * aspectRatio;
+ }
+
+ // Checks if the crop window is too large horizontally due to aspect ratio adjustment
+ if (newWidth > mMaxCropWidth) {
+ newTop = Math.max(bounds.top, rect.bottom - (mMaxCropWidth / aspectRatio));
+ newWidth = (rect.bottom - newTop) * aspectRatio;
+ }
+
+ // if left AND right edge moves by aspect ratio check that it is within full width bounds
+ if (leftMoves && rightMoves) {
+ newTop = Math.max(newTop, Math.max(bounds.top, rect.bottom - bounds.width() / aspectRatio));
+ } else {
+ // if left edge moves by aspect ratio check that it is within bounds
+ if (leftMoves && rect.right - newWidth < bounds.left) {
+ newTop = Math.max(bounds.top, rect.bottom - (rect.right - bounds.left) / aspectRatio);
+ newWidth = (rect.bottom - newTop) * aspectRatio;
+ }
+
+ // if right edge moves by aspect ratio check that it is within bounds
+ if (rightMoves && rect.left + newWidth > bounds.right) {
+ newTop =
+ Math.max(
+ newTop,
+ Math.max(bounds.top, rect.bottom - (bounds.right - rect.left) / aspectRatio));
+ }
+ }
+ }
+
+ rect.top = newTop;
+ }
+
+ /**
+ * Get the resulting y-position of the bottom edge of the crop window given the handle's position
+ * and the image's bounding box and snap radius.
+ *
+ * @param bottom the position that the bottom edge is dragged to
+ * @param bounds the bounding box of the image that is being cropped
+ * @param viewHeight
+ * @param snapMargin the snap distance to the image edge (in pixels)
+ */
+ private void adjustBottom(
+ RectF rect,
+ float bottom,
+ RectF bounds,
+ int viewHeight,
+ float snapMargin,
+ float aspectRatio,
+ boolean leftMoves,
+ boolean rightMoves) {
+
+ float newBottom = bottom;
+
+ if (newBottom > viewHeight) {
+ newBottom = viewHeight + (newBottom - viewHeight) / 1.05f;
+ mTouchOffset.y -= (newBottom - viewHeight) / 1.1f;
+ }
+
+ if (newBottom > bounds.bottom) {
+ mTouchOffset.y -= (newBottom - bounds.bottom) / 2f;
+ }
+
+ if (bounds.bottom - newBottom < snapMargin) {
+ newBottom = bounds.bottom;
+ }
+
+ // Checks if the window is too small vertically
+ if (newBottom - rect.top < mMinCropHeight) {
+ newBottom = rect.top + mMinCropHeight;
+ }
+
+ // Checks if the window is too small vertically
+ if (newBottom - rect.top > mMaxCropHeight) {
+ newBottom = rect.top + mMaxCropHeight;
+ }
+
+ if (bounds.bottom - newBottom < snapMargin) {
+ newBottom = bounds.bottom;
+ }
+
+ // check horizontal bounds if aspect ratio is in play
+ if (aspectRatio > 0) {
+ float newWidth = (newBottom - rect.top) * aspectRatio;
+
+ // Checks if the window is too small horizontally
+ if (newWidth < mMinCropWidth) {
+ newBottom = Math.min(bounds.bottom, rect.top + mMinCropWidth / aspectRatio);
+ newWidth = (newBottom - rect.top) * aspectRatio;
+ }
+
+ // Checks if the window is too large horizontally
+ if (newWidth > mMaxCropWidth) {
+ newBottom = Math.min(bounds.bottom, rect.top + mMaxCropWidth / aspectRatio);
+ newWidth = (newBottom - rect.top) * aspectRatio;
+ }
+
+ // if left AND right edge moves by aspect ratio check that it is within full width bounds
+ if (leftMoves && rightMoves) {
+ newBottom =
+ Math.min(newBottom, Math.min(bounds.bottom, rect.top + bounds.width() / aspectRatio));
+ } else {
+ // if left edge moves by aspect ratio check that it is within bounds
+ if (leftMoves && rect.right - newWidth < bounds.left) {
+ newBottom = Math.min(bounds.bottom, rect.top + (rect.right - bounds.left) / aspectRatio);
+ newWidth = (newBottom - rect.top) * aspectRatio;
+ }
+
+ // if right edge moves by aspect ratio check that it is within bounds
+ if (rightMoves && rect.left + newWidth > bounds.right) {
+ newBottom =
+ Math.min(
+ newBottom,
+ Math.min(bounds.bottom, rect.top + (bounds.right - rect.left) / aspectRatio));
+ }
+ }
+ }
+
+ rect.bottom = newBottom;
+ }
+
+ /**
+ * Adjust left edge by current crop window height and the given aspect ratio, the right edge
+ * remains in possition while the left adjusts to keep aspect ratio to the height.
+ */
+ private void adjustLeftByAspectRatio(RectF rect, float aspectRatio) {
+ rect.left = rect.right - rect.height() * aspectRatio;
+ }
+
+ /**
+ * Adjust top edge by current crop window width and the given aspect ratio, the bottom edge
+ * remains in possition while the top adjusts to keep aspect ratio to the width.
+ */
+ private void adjustTopByAspectRatio(RectF rect, float aspectRatio) {
+ rect.top = rect.bottom - rect.width() / aspectRatio;
+ }
+
+ /**
+ * Adjust right edge by current crop window height and the given aspect ratio, the left edge
+ * remains in possition while the left adjusts to keep aspect ratio to the height.
+ */
+ private void adjustRightByAspectRatio(RectF rect, float aspectRatio) {
+ rect.right = rect.left + rect.height() * aspectRatio;
+ }
+
+ /**
+ * Adjust bottom edge by current crop window width and the given aspect ratio, the top edge
+ * remains in possition while the top adjusts to keep aspect ratio to the width.
+ */
+ private void adjustBottomByAspectRatio(RectF rect, float aspectRatio) {
+ rect.bottom = rect.top + rect.width() / aspectRatio;
+ }
+
+ /**
+ * Adjust left and right edges by current crop window height and the given aspect ratio, both
+ * right and left edges adjusts equally relative to center to keep aspect ratio to the height.
+ */
+ private void adjustLeftRightByAspectRatio(RectF rect, RectF bounds, float aspectRatio) {
+ rect.inset((rect.width() - rect.height() * aspectRatio) / 2, 0);
+ if (rect.left < bounds.left) {
+ rect.offset(bounds.left - rect.left, 0);
+ }
+ if (rect.right > bounds.right) {
+ rect.offset(bounds.right - rect.right, 0);
+ }
+ }
+
+ /**
+ * Adjust top and bottom edges by current crop window width and the given aspect ratio, both top
+ * and bottom edges adjusts equally relative to center to keep aspect ratio to the width.
+ */
+ private void adjustTopBottomByAspectRatio(RectF rect, RectF bounds, float aspectRatio) {
+ rect.inset(0, (rect.height() - rect.width() / aspectRatio) / 2);
+ if (rect.top < bounds.top) {
+ rect.offset(0, bounds.top - rect.top);
+ }
+ if (rect.bottom > bounds.bottom) {
+ rect.offset(0, bounds.bottom - rect.bottom);
+ }
+ }
+
+ /** Calculates the aspect ratio given a rectangle. */
+ private static float calculateAspectRatio(float left, float top, float right, float bottom) {
+ return (right - left) / (bottom - top);
+ }
+ // endregion
+
+ // region: Inner class: Type
+
+ /** The type of crop window move that is handled. */
+ public enum Type {
+ TOP_LEFT,
+ TOP_RIGHT,
+ BOTTOM_LEFT,
+ BOTTOM_RIGHT,
+ LEFT,
+ TOP,
+ RIGHT,
+ BOTTOM,
+ CENTER
+ }
+ // endregion
+}
diff --git a/cropper/src/main/res/drawable-hdpi/crop_image_menu_flip.png b/cropper/src/main/res/drawable-hdpi/crop_image_menu_flip.png
new file mode 100644
index 0000000..133395d
Binary files /dev/null and b/cropper/src/main/res/drawable-hdpi/crop_image_menu_flip.png differ
diff --git a/cropper/src/main/res/drawable-hdpi/crop_image_menu_rotate_left.png b/cropper/src/main/res/drawable-hdpi/crop_image_menu_rotate_left.png
new file mode 100644
index 0000000..e4e26f8
Binary files /dev/null and b/cropper/src/main/res/drawable-hdpi/crop_image_menu_rotate_left.png differ
diff --git a/cropper/src/main/res/drawable-hdpi/crop_image_menu_rotate_right.png b/cropper/src/main/res/drawable-hdpi/crop_image_menu_rotate_right.png
new file mode 100644
index 0000000..2311d1a
Binary files /dev/null and b/cropper/src/main/res/drawable-hdpi/crop_image_menu_rotate_right.png differ
diff --git a/cropper/src/main/res/drawable-xhdpi/crop_image_menu_flip.png b/cropper/src/main/res/drawable-xhdpi/crop_image_menu_flip.png
new file mode 100644
index 0000000..79910ff
Binary files /dev/null and b/cropper/src/main/res/drawable-xhdpi/crop_image_menu_flip.png differ
diff --git a/cropper/src/main/res/drawable-xhdpi/crop_image_menu_rotate_left.png b/cropper/src/main/res/drawable-xhdpi/crop_image_menu_rotate_left.png
new file mode 100644
index 0000000..bdfcbca
Binary files /dev/null and b/cropper/src/main/res/drawable-xhdpi/crop_image_menu_rotate_left.png differ
diff --git a/cropper/src/main/res/drawable-xhdpi/crop_image_menu_rotate_right.png b/cropper/src/main/res/drawable-xhdpi/crop_image_menu_rotate_right.png
new file mode 100644
index 0000000..6d73012
Binary files /dev/null and b/cropper/src/main/res/drawable-xhdpi/crop_image_menu_rotate_right.png differ
diff --git a/cropper/src/main/res/drawable-xxhdpi/crop_image_menu_flip.png b/cropper/src/main/res/drawable-xxhdpi/crop_image_menu_flip.png
new file mode 100644
index 0000000..3629e38
Binary files /dev/null and b/cropper/src/main/res/drawable-xxhdpi/crop_image_menu_flip.png differ
diff --git a/cropper/src/main/res/drawable-xxhdpi/crop_image_menu_rotate_left.png b/cropper/src/main/res/drawable-xxhdpi/crop_image_menu_rotate_left.png
new file mode 100644
index 0000000..5ae4f53
Binary files /dev/null and b/cropper/src/main/res/drawable-xxhdpi/crop_image_menu_rotate_left.png differ
diff --git a/cropper/src/main/res/drawable-xxhdpi/crop_image_menu_rotate_right.png b/cropper/src/main/res/drawable-xxhdpi/crop_image_menu_rotate_right.png
new file mode 100644
index 0000000..796114c
Binary files /dev/null and b/cropper/src/main/res/drawable-xxhdpi/crop_image_menu_rotate_right.png differ
diff --git a/cropper/src/main/res/drawable-xxxhdpi/crop_image_menu_flip.png b/cropper/src/main/res/drawable-xxxhdpi/crop_image_menu_flip.png
new file mode 100644
index 0000000..4200cb8
Binary files /dev/null and b/cropper/src/main/res/drawable-xxxhdpi/crop_image_menu_flip.png differ
diff --git a/cropper/src/main/res/drawable-xxxhdpi/crop_image_menu_rotate_left.png b/cropper/src/main/res/drawable-xxxhdpi/crop_image_menu_rotate_left.png
new file mode 100644
index 0000000..1eb6861
Binary files /dev/null and b/cropper/src/main/res/drawable-xxxhdpi/crop_image_menu_rotate_left.png differ
diff --git a/cropper/src/main/res/drawable-xxxhdpi/crop_image_menu_rotate_right.png b/cropper/src/main/res/drawable-xxxhdpi/crop_image_menu_rotate_right.png
new file mode 100644
index 0000000..33ce670
Binary files /dev/null and b/cropper/src/main/res/drawable-xxxhdpi/crop_image_menu_rotate_right.png differ
diff --git a/cropper/src/main/res/layout/crop_image_activity.xml b/cropper/src/main/res/layout/crop_image_activity.xml
new file mode 100644
index 0000000..dfc19ca
--- /dev/null
+++ b/cropper/src/main/res/layout/crop_image_activity.xml
@@ -0,0 +1,6 @@
+
+
\ No newline at end of file
diff --git a/cropper/src/main/res/layout/crop_image_view.xml b/cropper/src/main/res/layout/crop_image_view.xml
new file mode 100644
index 0000000..003155a
--- /dev/null
+++ b/cropper/src/main/res/layout/crop_image_view.xml
@@ -0,0 +1,25 @@
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/cropper/src/main/res/menu/crop_image_menu.xml b/cropper/src/main/res/menu/crop_image_menu.xml
new file mode 100644
index 0000000..a95ca97
--- /dev/null
+++ b/cropper/src/main/res/menu/crop_image_menu.xml
@@ -0,0 +1,35 @@
+
+
\ No newline at end of file
diff --git a/cropper/src/main/res/values-ar/strings.xml b/cropper/src/main/res/values-ar/strings.xml
new file mode 100644
index 0000000..4b57098
--- /dev/null
+++ b/cropper/src/main/res/values-ar/strings.xml
@@ -0,0 +1,16 @@
+
+
+
+
+ أدر عكس اتجاه عقارب الساعة
+ أدر
+ قُصّ
+ اقلب
+ اقلب أفقيًا
+ اقلب رأسيًا
+
+ اختر مصدرًا
+
+ إلغاء؛ الأذونات المطلوبة غير ممنوحة
+
+
diff --git a/cropper/src/main/res/values-cs/strings.xml b/cropper/src/main/res/values-cs/strings.xml
new file mode 100644
index 0000000..a8cef3c
--- /dev/null
+++ b/cropper/src/main/res/values-cs/strings.xml
@@ -0,0 +1,16 @@
+
+
+
+
+ Otočit proti směru hodinových ručiček
+ Otočit
+ Oříznout
+ Překlopit
+ Překlopit vodorovně
+ Překlopit svisle
+
+ Vybrat zdroj
+
+ Probíhá storno, požadovaná povolení nejsou udělena
+
+
diff --git a/cropper/src/main/res/values-de/strings.xml b/cropper/src/main/res/values-de/strings.xml
new file mode 100644
index 0000000..1ef0f3d
--- /dev/null
+++ b/cropper/src/main/res/values-de/strings.xml
@@ -0,0 +1,16 @@
+
+
+
+
+ gegen den Uhrzeigersinn drehen
+ drehen
+ zuschneiden
+ spiegeln
+ horizontal spiegeln
+ vertikal spiegeln
+
+ Quelle wählen
+
+ Vorgang wird abgebrochen, benötigte Berechtigungen wurden nicht erteilt.
+
+
diff --git a/cropper/src/main/res/values-es-rGT/strings.xml b/cropper/src/main/res/values-es-rGT/strings.xml
new file mode 100644
index 0000000..c9b0864
--- /dev/null
+++ b/cropper/src/main/res/values-es-rGT/strings.xml
@@ -0,0 +1,12 @@
+
+
+
+ Girar a la izquierda
+ Girar a la derecha
+ Cortar
+ Dar la vuelta
+ Voltear horizontalmente
+ Voltear verticalmente
+ Seleccionar fuente
+ Cancelando, los permisos requeridos no se otorgaron
+
\ No newline at end of file
diff --git a/cropper/src/main/res/values-es/strings.xml b/cropper/src/main/res/values-es/strings.xml
new file mode 100644
index 0000000..a14c240
--- /dev/null
+++ b/cropper/src/main/res/values-es/strings.xml
@@ -0,0 +1,12 @@
+
+
+
+ Rotar a la izquierda
+ Rotar a la derecha
+ Cortar
+ Dar la vuelta
+ Voltear horizontalmente
+ Voltear verticalmente
+ Seleccionar fuente
+ Cancelando, los permisos requeridos no han sido otorgados
+
\ No newline at end of file
diff --git a/cropper/src/main/res/values-fa/strings.xml b/cropper/src/main/res/values-fa/strings.xml
new file mode 100644
index 0000000..b674574
--- /dev/null
+++ b/cropper/src/main/res/values-fa/strings.xml
@@ -0,0 +1,11 @@
+
+
+ چرخش در جهت عقربه های ساعت
+ چرخش
+ بریدن (کراپ)
+ آیینه کردن
+ آیینه کردن به صورت افقی
+ آیینه کردن به صورت عمودی
+ منبع را انتخاب کنید
+ لغو، مجوزهای مورد نیاز ارائه نشده
+
\ No newline at end of file
diff --git a/cropper/src/main/res/values-fr/strings.xml b/cropper/src/main/res/values-fr/strings.xml
new file mode 100644
index 0000000..b0ec3bd
--- /dev/null
+++ b/cropper/src/main/res/values-fr/strings.xml
@@ -0,0 +1,12 @@
+
+
+
+ Pivoter à gauche
+ Pivoter à droite
+ Redimensionner
+ Retourner
+ Retourner horizontalement
+ Retourner verticalement
+ Sélectionner la source
+ Annulation, il manque des permissions requises
+
diff --git a/cropper/src/main/res/values-he/strings.xml b/cropper/src/main/res/values-he/strings.xml
new file mode 100644
index 0000000..8a781d3
--- /dev/null
+++ b/cropper/src/main/res/values-he/strings.xml
@@ -0,0 +1,16 @@
+
+
+
+
+ סובב נגד כיוון השעון
+ סובב
+ חתוך
+ הפוך
+ הפוך אופקית
+ הפוך אנכית
+
+ בחר מקור
+
+ ההרשאות הנדרשות חסרות, מבטל
+
+
diff --git a/cropper/src/main/res/values-hi/strings.xml b/cropper/src/main/res/values-hi/strings.xml
new file mode 100644
index 0000000..8549a12
--- /dev/null
+++ b/cropper/src/main/res/values-hi/strings.xml
@@ -0,0 +1,11 @@
+
+
+ घड़ी की सुई के विपरीत दिशा में घुमाइए
+ घुमाएँ
+ फ़सल
+ फ्लिप
+ क्षैतिज फ्लिप
+ लंबवत फ्लिप करें
+ सोर्स चुनें
+ रद्द करना, आवश्यक अनुमतियां नहीं दी गई हैं
+
\ No newline at end of file
diff --git a/cropper/src/main/res/values-id/strings.xml b/cropper/src/main/res/values-id/strings.xml
new file mode 100644
index 0000000..5d0570e
--- /dev/null
+++ b/cropper/src/main/res/values-id/strings.xml
@@ -0,0 +1,12 @@
+
+
+ Putar berlawanan arah jarum jam
+ Putar
+ Potong
+ Balik
+ Balik secara horizontal
+ Balik secara vertikal
+ Pilih sumber
+ Membatalkan, tidak mendapatkan izin yang diperlukan
+
+
\ No newline at end of file
diff --git a/cropper/src/main/res/values-in/strings.xml b/cropper/src/main/res/values-in/strings.xml
new file mode 100644
index 0000000..5d0570e
--- /dev/null
+++ b/cropper/src/main/res/values-in/strings.xml
@@ -0,0 +1,12 @@
+
+
+ Putar berlawanan arah jarum jam
+ Putar
+ Potong
+ Balik
+ Balik secara horizontal
+ Balik secara vertikal
+ Pilih sumber
+ Membatalkan, tidak mendapatkan izin yang diperlukan
+
+
\ No newline at end of file
diff --git a/cropper/src/main/res/values-it/strings.xml b/cropper/src/main/res/values-it/strings.xml
new file mode 100644
index 0000000..fa26666
--- /dev/null
+++ b/cropper/src/main/res/values-it/strings.xml
@@ -0,0 +1,16 @@
+
+
+
+
+ Ruota in senso antiorario
+ Ruota
+ Ritaglia
+ Capovolgi
+ Capovolgi orizzontalmente
+ Capovolgi verticalmente
+
+ Seleziona origine
+
+ Annullamento in corso, autorizzazione richieste non concesse
+
+
diff --git a/cropper/src/main/res/values-ja/strings.xml b/cropper/src/main/res/values-ja/strings.xml
new file mode 100644
index 0000000..4ab0ca5
--- /dev/null
+++ b/cropper/src/main/res/values-ja/strings.xml
@@ -0,0 +1,11 @@
+
+
+ 左回転
+ 右回転
+ 切り取り
+ 反転
+ 左右反転
+ 上下反転
+ 画像を選択
+ 必要な権限がありません、キャンセルしています。
+
diff --git a/cropper/src/main/res/values-ko/strings.xml b/cropper/src/main/res/values-ko/strings.xml
new file mode 100644
index 0000000..a33967b
--- /dev/null
+++ b/cropper/src/main/res/values-ko/strings.xml
@@ -0,0 +1,16 @@
+
+
+
+
+ 반시계 회전
+ 회전
+ 자르기
+ 반전
+ 좌우반전
+ 상하반전
+
+ 이미지 선택
+
+ 필수 권한이 없어서 취소합니다.
+
+
diff --git a/cropper/src/main/res/values-ms/strings.xml b/cropper/src/main/res/values-ms/strings.xml
new file mode 100644
index 0000000..000ac2e
--- /dev/null
+++ b/cropper/src/main/res/values-ms/strings.xml
@@ -0,0 +1,12 @@
+
+
+ Putar arah berlawanan jam
+ Putar
+ Potong
+ Flip
+ Flip melintang
+ Flip menegak
+ Pilih sumber
+ Membatal, tidak mendapat kebenaran yang diperlukan
+
+
\ No newline at end of file
diff --git a/cropper/src/main/res/values-nb/strings.xml b/cropper/src/main/res/values-nb/strings.xml
new file mode 100644
index 0000000..c177d25
--- /dev/null
+++ b/cropper/src/main/res/values-nb/strings.xml
@@ -0,0 +1,16 @@
+
+
+
+
+ Roter teller med urviseren
+ Roter
+ Beskjær
+ Vend
+ Vend vannrett
+ Vend loddrett
+
+ Velg kilde
+
+ Avbryter, nødvendige tillatelser er ikke gitt
+
+
diff --git a/cropper/src/main/res/values-nl/strings.xml b/cropper/src/main/res/values-nl/strings.xml
new file mode 100644
index 0000000..6b25e03
--- /dev/null
+++ b/cropper/src/main/res/values-nl/strings.xml
@@ -0,0 +1,16 @@
+
+
+
+
+ Tegen de klok in draaien
+ Draaien
+ Bijsnijden
+ Spiegelen
+ Horizontaal spiegelen
+ Verticaal spiegelen
+
+ Bron selecteren
+
+ Wordt geannuleerd, vereiste machtigingen zijn niet toegekend
+
+
diff --git a/cropper/src/main/res/values-pl/strings.xml b/cropper/src/main/res/values-pl/strings.xml
new file mode 100644
index 0000000..9db22a1
--- /dev/null
+++ b/cropper/src/main/res/values-pl/strings.xml
@@ -0,0 +1,16 @@
+
+
+
+
+ Obróć w lewo
+ Obróć
+ Przytnij
+ Odbij
+ Odbij poziomo
+ Odbij pionowo
+
+ Wybierz źródło
+
+ Przerywaniem, potrzebne uprawnienia nie zostały nadane
+
+
diff --git a/cropper/src/main/res/values-pt-rBR/strings.xml b/cropper/src/main/res/values-pt-rBR/strings.xml
new file mode 100644
index 0000000..e60b8bf
--- /dev/null
+++ b/cropper/src/main/res/values-pt-rBR/strings.xml
@@ -0,0 +1,14 @@
+
+
+
+
+ Girar para a esquerda
+ Girar para a direita
+ Cortar
+ Espelhar
+ Espelhar na horizontal
+ Espelhar na vertifcal
+
+ Escolher foto a partir de
+
+
diff --git a/cropper/src/main/res/values-ru-rRU/strings.xml b/cropper/src/main/res/values-ru-rRU/strings.xml
new file mode 100644
index 0000000..cd8e62d
--- /dev/null
+++ b/cropper/src/main/res/values-ru-rRU/strings.xml
@@ -0,0 +1,10 @@
+
+
+ Повернуть налево
+ Повернуть направо
+ Обрезать
+ Отразить
+ Отразить по горизонтали
+ Отразить по вертикали
+ Выбрать источник
+
\ No newline at end of file
diff --git a/cropper/src/main/res/values-sv/strings.xml b/cropper/src/main/res/values-sv/strings.xml
new file mode 100644
index 0000000..ce8beb9
--- /dev/null
+++ b/cropper/src/main/res/values-sv/strings.xml
@@ -0,0 +1,16 @@
+
+
+
+
+ Rotera vänster
+ Rotera höger
+ Beskär
+ Vänd
+ Vänd horisontellt
+ Vänd vertikalt
+
+ Välj bild
+
+ Avbryter, nödvändiga behörigheter beviljas inte
+
+
diff --git a/cropper/src/main/res/values-tr/strings.xml b/cropper/src/main/res/values-tr/strings.xml
new file mode 100644
index 0000000..ba0f6f4
--- /dev/null
+++ b/cropper/src/main/res/values-tr/strings.xml
@@ -0,0 +1,12 @@
+
+
+ Saat yönünde döndür
+ döndürmek
+ ekin
+ fiske
+ Yatay olarak çevir
+ Dikey olarak çevir
+ Kaynağı seçin
+ İptal ediliyor, gerekli izinler verilmiyor
+
+
\ No newline at end of file
diff --git a/cropper/src/main/res/values-ur/strings.xml b/cropper/src/main/res/values-ur/strings.xml
new file mode 100644
index 0000000..b62a923
--- /dev/null
+++ b/cropper/src/main/res/values-ur/strings.xml
@@ -0,0 +1,12 @@
+
+
+ گھڑی وار گھڑی گھومیں
+ گھمائیں
+ فصل
+ پلٹائیں
+ افقی پلٹائیں
+ عمودی طور پر پلٹائیں
+ ذریعہ منتخب کریں
+ منسوخ کرنا، ضروری اجازت نہیں دی جاتی ہیں
+
+
\ No newline at end of file
diff --git a/cropper/src/main/res/values-vi/strings.xml b/cropper/src/main/res/values-vi/strings.xml
new file mode 100644
index 0000000..d6301f4
--- /dev/null
+++ b/cropper/src/main/res/values-vi/strings.xml
@@ -0,0 +1,16 @@
+
+
+
+
+ Xoay theo chiều kim đồng hồ
+ Xoay
+ Cắt
+ Lật
+ Lật theo chiều ngang
+ Lật theo chiều dọc
+
+ Chọn nguồn
+
+ Đang hủy, các quyền đã yêu cầu không được cấp
+
+
diff --git a/cropper/src/main/res/values-zh-rCN/strings.xml b/cropper/src/main/res/values-zh-rCN/strings.xml
new file mode 100644
index 0000000..7ceb799
--- /dev/null
+++ b/cropper/src/main/res/values-zh-rCN/strings.xml
@@ -0,0 +1,16 @@
+
+
+
+
+ 逆时针旋转
+ 旋转
+ 裁切
+ 翻转
+ 水平翻转
+ 垂直翻转
+
+ 选择来源
+
+ 取消中,未授予所需权限
+
+
\ No newline at end of file
diff --git a/cropper/src/main/res/values-zh-rTW/strings.xml b/cropper/src/main/res/values-zh-rTW/strings.xml
new file mode 100644
index 0000000..269b965
--- /dev/null
+++ b/cropper/src/main/res/values-zh-rTW/strings.xml
@@ -0,0 +1,16 @@
+
+
+
+
+ 逆時針旋轉
+ 旋轉
+ 裁切
+ 翻轉
+ 水平翻轉
+ 垂直翻轉
+
+ 選擇來源
+
+ 取消中,未授予所需權限
+
+
\ No newline at end of file
diff --git a/cropper/src/main/res/values-zh/strings.xml b/cropper/src/main/res/values-zh/strings.xml
new file mode 100644
index 0000000..b197f48
--- /dev/null
+++ b/cropper/src/main/res/values-zh/strings.xml
@@ -0,0 +1,16 @@
+
+
+
+
+ 逆时针旋转
+ 旋转
+ 裁剪
+ 翻转
+ 水平翻转
+ 垂直翻转
+
+ 选择来源
+
+ 正在取消,该操作未获得所需权限。
+
+
diff --git a/cropper/src/main/res/values/attrs.xml b/cropper/src/main/res/values/attrs.xml
new file mode 100644
index 0000000..41579fb
--- /dev/null
+++ b/cropper/src/main/res/values/attrs.xml
@@ -0,0 +1,50 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/cropper/src/main/res/values/strings.xml b/cropper/src/main/res/values/strings.xml
new file mode 100644
index 0000000..6dc1fd2
--- /dev/null
+++ b/cropper/src/main/res/values/strings.xml
@@ -0,0 +1,16 @@
+
+
+
+
+ Rotate counter clockwise
+ Rotate
+ Crop
+ Flip
+ Flip horizontally
+ Flip vertically
+
+ Select source
+
+ Cancelling, required permissions are not granted
+
+
diff --git a/cropper/src/test/java/com/theartofdev/edmodo/cropper/ExampleUnitTest.kt b/cropper/src/test/java/com/theartofdev/edmodo/cropper/ExampleUnitTest.kt
new file mode 100644
index 0000000..2b7bc2c
--- /dev/null
+++ b/cropper/src/test/java/com/theartofdev/edmodo/cropper/ExampleUnitTest.kt
@@ -0,0 +1,17 @@
+package com.theartofdev.edmodo.cropper
+
+import org.junit.Test
+
+import org.junit.Assert.*
+
+/**
+ * Example local unit test, which will execute on the development machine (host).
+ *
+ * See [testing documentation](http://d.android.com/tools/testing).
+ */
+class ExampleUnitTest {
+ @Test
+ fun addition_isCorrect() {
+ assertEquals(4, 2 + 2)
+ }
+}
\ No newline at end of file
diff --git a/pageindicatorview/.gitignore b/pageindicatorview/.gitignore
new file mode 100644
index 0000000..42afabf
--- /dev/null
+++ b/pageindicatorview/.gitignore
@@ -0,0 +1 @@
+/build
\ No newline at end of file
diff --git a/pageindicatorview/build.gradle b/pageindicatorview/build.gradle
new file mode 100644
index 0000000..37ad037
--- /dev/null
+++ b/pageindicatorview/build.gradle
@@ -0,0 +1,40 @@
+plugins {
+ id 'com.android.library'
+ id 'org.jetbrains.kotlin.android'
+}
+
+android {
+ namespace 'com.rd.pageindicatorview'
+ compileSdk 34
+
+ defaultConfig {
+ minSdk 21
+
+ testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
+ consumerProguardFiles "consumer-rules.pro"
+ }
+
+ buildTypes {
+ release {
+ minifyEnabled false
+ proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
+ }
+ }
+ compileOptions {
+ sourceCompatibility JavaVersion.VERSION_1_8
+ targetCompatibility JavaVersion.VERSION_1_8
+ }
+ kotlinOptions {
+ jvmTarget = '1.8'
+ }
+}
+
+dependencies {
+
+ implementation 'androidx.core:core-ktx:1.13.1'
+ implementation 'androidx.appcompat:appcompat:1.7.0'
+ implementation 'com.google.android.material:material:1.12.0'
+ testImplementation 'junit:junit:4.13.2'
+ androidTestImplementation 'androidx.test.ext:junit:1.2.1'
+ androidTestImplementation 'androidx.test.espresso:espresso-core:3.6.1'
+}
\ No newline at end of file
diff --git a/pageindicatorview/consumer-rules.pro b/pageindicatorview/consumer-rules.pro
new file mode 100644
index 0000000..e69de29
diff --git a/pageindicatorview/proguard-rules.pro b/pageindicatorview/proguard-rules.pro
new file mode 100644
index 0000000..481bb43
--- /dev/null
+++ b/pageindicatorview/proguard-rules.pro
@@ -0,0 +1,21 @@
+# Add project specific ProGuard rules here.
+# You can control the set of applied configuration files using the
+# proguardFiles setting in build.gradle.
+#
+# For more details, see
+# http://developer.android.com/guide/developing/tools/proguard.html
+
+# If your project uses WebView with JS, uncomment the following
+# and specify the fully qualified class name to the JavaScript interface
+# class:
+#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
+# public *;
+#}
+
+# Uncomment this to preserve the line number information for
+# debugging stack traces.
+#-keepattributes SourceFile,LineNumberTable
+
+# If you keep the line number information, uncomment this to
+# hide the original source file name.
+#-renamesourcefileattribute SourceFile
\ No newline at end of file
diff --git a/pageindicatorview/src/androidTest/java/com/rd/pageindicatorview/ExampleInstrumentedTest.kt b/pageindicatorview/src/androidTest/java/com/rd/pageindicatorview/ExampleInstrumentedTest.kt
new file mode 100644
index 0000000..0a01596
--- /dev/null
+++ b/pageindicatorview/src/androidTest/java/com/rd/pageindicatorview/ExampleInstrumentedTest.kt
@@ -0,0 +1,24 @@
+package com.rd.pageindicatorview
+
+import androidx.test.platform.app.InstrumentationRegistry
+import androidx.test.ext.junit.runners.AndroidJUnit4
+
+import org.junit.Test
+import org.junit.runner.RunWith
+
+import org.junit.Assert.*
+
+/**
+ * Instrumented test, which will execute on an Android device.
+ *
+ * See [testing documentation](http://d.android.com/tools/testing).
+ */
+@RunWith(AndroidJUnit4::class)
+class ExampleInstrumentedTest {
+ @Test
+ fun useAppContext() {
+ // Context of the app under test.
+ val appContext = InstrumentationRegistry.getInstrumentation().targetContext
+ assertEquals("com.rd.pageindicatorview.test", appContext.packageName)
+ }
+}
\ No newline at end of file
diff --git a/pageindicatorview/src/main/AndroidManifest.xml b/pageindicatorview/src/main/AndroidManifest.xml
new file mode 100644
index 0000000..a5918e6
--- /dev/null
+++ b/pageindicatorview/src/main/AndroidManifest.xml
@@ -0,0 +1,4 @@
+
+
+
+
\ No newline at end of file
diff --git a/pageindicatorview/src/main/java/com/rd/IndicatorManager.java b/pageindicatorview/src/main/java/com/rd/IndicatorManager.java
new file mode 100644
index 0000000..0485e6d
--- /dev/null
+++ b/pageindicatorview/src/main/java/com/rd/IndicatorManager.java
@@ -0,0 +1,45 @@
+package com.rd;
+
+import androidx.annotation.Nullable;
+import com.rd.animation.AnimationManager;
+import com.rd.animation.controller.ValueController;
+import com.rd.animation.data.Value;
+import com.rd.draw.DrawManager;
+import com.rd.draw.data.Indicator;
+
+public class IndicatorManager implements ValueController.UpdateListener {
+
+ private DrawManager drawManager;
+ private AnimationManager animationManager;
+ private Listener listener;
+
+ interface Listener {
+ void onIndicatorUpdated();
+ }
+
+ IndicatorManager(@Nullable Listener listener) {
+ this.listener = listener;
+ this.drawManager = new DrawManager();
+ this.animationManager = new AnimationManager(drawManager.indicator(), this);
+ }
+
+ public AnimationManager animate() {
+ return animationManager;
+ }
+
+ public Indicator indicator() {
+ return drawManager.indicator();
+ }
+
+ public DrawManager drawer() {
+ return drawManager;
+ }
+
+ @Override
+ public void onValueUpdated(@Nullable Value value) {
+ drawManager.updateValue(value);
+ if (listener != null) {
+ listener.onIndicatorUpdated();
+ }
+ }
+}
diff --git a/pageindicatorview/src/main/java/com/rd/PageIndicatorView.java b/pageindicatorview/src/main/java/com/rd/PageIndicatorView.java
new file mode 100644
index 0000000..6d99352
--- /dev/null
+++ b/pageindicatorview/src/main/java/com/rd/PageIndicatorView.java
@@ -0,0 +1,892 @@
+package com.rd;
+
+import android.annotation.SuppressLint;
+import android.annotation.TargetApi;
+import android.content.Context;
+import android.database.DataSetObserver;
+import android.graphics.Canvas;
+import android.os.Build;
+import android.os.Handler;
+import android.os.Looper;
+import android.os.Parcelable;
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.core.text.TextUtilsCompat;
+import androidx.viewpager.widget.PagerAdapter;
+import androidx.core.view.ViewCompat;
+import androidx.viewpager.widget.ViewPager;
+import android.util.AttributeSet;
+import android.util.Pair;
+import android.view.MotionEvent;
+import android.view.View;
+import android.view.ViewGroup;
+import android.view.ViewParent;
+import com.rd.animation.type.*;
+import com.rd.draw.controller.DrawController;
+import com.rd.draw.data.Indicator;
+import com.rd.draw.data.Orientation;
+import com.rd.draw.data.PositionSavedState;
+import com.rd.draw.data.RtlMode;
+import com.rd.utils.CoordinatesUtils;
+import com.rd.utils.DensityUtils;
+import com.rd.utils.IdUtils;
+
+public class PageIndicatorView extends View implements ViewPager.OnPageChangeListener, IndicatorManager.Listener, ViewPager.OnAdapterChangeListener, View.OnTouchListener {
+
+ private static final Handler HANDLER = new Handler(Looper.getMainLooper());
+
+ private IndicatorManager manager;
+ private DataSetObserver setObserver;
+ private ViewPager viewPager;
+ private boolean isInteractionEnabled;
+
+ public PageIndicatorView(Context context) {
+ super(context);
+ init(null);
+ }
+
+ public PageIndicatorView(Context context, AttributeSet attrs) {
+ super(context, attrs);
+ init(attrs);
+ }
+
+ public PageIndicatorView(Context context, AttributeSet attrs, int defStyleAttr) {
+ super(context, attrs, defStyleAttr);
+ init(attrs);
+ }
+
+ @TargetApi(Build.VERSION_CODES.LOLLIPOP)
+ public PageIndicatorView(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) {
+ super(context, attrs, defStyleAttr, defStyleRes);
+ init(attrs);
+ }
+
+ @Override
+ protected void onAttachedToWindow() {
+ super.onAttachedToWindow();
+ findViewPager(getParent());
+ }
+
+ @Override
+ protected void onDetachedFromWindow() {
+ unRegisterSetObserver();
+ super.onDetachedFromWindow();
+ }
+
+ @Override
+ public Parcelable onSaveInstanceState() {
+ Indicator indicator = manager.indicator();
+ PositionSavedState positionSavedState = new PositionSavedState(super.onSaveInstanceState());
+ positionSavedState.setSelectedPosition(indicator.getSelectedPosition());
+ positionSavedState.setSelectingPosition(indicator.getSelectingPosition());
+ positionSavedState.setLastSelectedPosition(indicator.getLastSelectedPosition());
+
+ return positionSavedState;
+ }
+
+ @Override
+ public void onRestoreInstanceState(Parcelable state) {
+ if (state instanceof PositionSavedState) {
+ Indicator indicator = manager.indicator();
+ PositionSavedState positionSavedState = (PositionSavedState) state;
+ indicator.setSelectedPosition(positionSavedState.getSelectedPosition());
+ indicator.setSelectingPosition(positionSavedState.getSelectingPosition());
+ indicator.setLastSelectedPosition(positionSavedState.getLastSelectedPosition());
+ super.onRestoreInstanceState(positionSavedState.getSuperState());
+
+ } else {
+ super.onRestoreInstanceState(state);
+ }
+ }
+
+ @Override
+ protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
+ Pair pair = manager.drawer().measureViewSize(widthMeasureSpec, heightMeasureSpec);
+ setMeasuredDimension(pair.first, pair.second);
+ }
+
+ @Override
+ protected void onDraw(Canvas canvas) {
+ manager.drawer().draw(canvas);
+ }
+
+ @SuppressLint("ClickableViewAccessibility")
+ @Override
+ public boolean onTouchEvent(MotionEvent event) {
+ manager.drawer().touch(event);
+ return true;
+ }
+
+ @Override
+ public boolean onTouch(View v, MotionEvent event) {
+ if (!manager.indicator().isFadeOnIdle()) return false;
+
+ switch (event.getAction()) {
+ case MotionEvent.ACTION_DOWN:
+ stopIdleRunnable();
+ break;
+
+ case MotionEvent.ACTION_UP:
+ startIdleRunnable();
+ break;
+ }
+ return false;
+ }
+
+ @Override
+ public void onIndicatorUpdated() {
+ invalidate();
+ }
+
+ @Override
+ public void onPageScrolled(int position, float positionOffset, int positionOffsetPixels) {
+ onPageScroll(position, positionOffset);
+ }
+
+ @Override
+ public void onPageSelected(int position) {
+ onPageSelect(position);
+ }
+
+ @Override
+ public void onPageScrollStateChanged(int state) {
+ if (state == ViewPager.SCROLL_STATE_IDLE) {
+ manager.indicator().setInteractiveAnimation(isInteractionEnabled);
+ }
+ }
+
+ @Override
+ public void onAdapterChanged(@NonNull ViewPager viewPager, @Nullable PagerAdapter oldAdapter, @Nullable PagerAdapter newAdapter) {
+ if (manager.indicator().isDynamicCount()) {
+ if (oldAdapter != null && setObserver != null) {
+ oldAdapter.unregisterDataSetObserver(setObserver);
+ setObserver = null;
+ }
+ registerSetObserver();
+ }
+ updateState();
+ }
+
+ /**
+ * Set static number of circle indicators to be displayed.
+ *
+ * @param count total count of indicators.
+ */
+ public void setCount(int count) {
+ if (count >= 0 && manager.indicator().getCount() != count) {
+ manager.indicator().setCount(count);
+ updateVisibility();
+ requestLayout();
+ }
+ }
+
+ /**
+ * Return number of circle indicators
+ */
+ public int getCount() {
+ return manager.indicator().getCount();
+ }
+
+ /**
+ * Dynamic count will automatically update number of circle indicators
+ * if {@link ViewPager} page count updates on run-time. If new count will be bigger than current count,
+ * selected circle will stay as it is, otherwise it will be set to last one.
+ * Note: works if {@link ViewPager} set and already have it's adapter. See {@link #setViewPager(ViewPager)}.
+ *
+ * @param dynamicCount boolean value to add/remove indicators dynamically.
+ */
+ public void setDynamicCount(boolean dynamicCount) {
+ manager.indicator().setDynamicCount(dynamicCount);
+
+ if (dynamicCount) {
+ registerSetObserver();
+ } else {
+ unRegisterSetObserver();
+ }
+ }
+
+ /**
+ * Fade on idle will make {@link PageIndicatorView} {@link View#INVISIBLE} if {@link ViewPager} is not interacted
+ * in time equal to {@link Indicator#idleDuration}. Take care when setting {@link PageIndicatorView} alpha
+ * manually if this is true. Alpha is used to manage fading and appearance of {@link PageIndicatorView} and value you provide
+ * will be overridden when {@link PageIndicatorView} enters or leaves idle state.
+ *
+ * @param fadeOnIdle boolean value to hide {@link PageIndicatorView} when {@link ViewPager} is idle
+ */
+ public void setFadeOnIdle(boolean fadeOnIdle) {
+ manager.indicator().setFadeOnIdle(fadeOnIdle);
+ if (fadeOnIdle) {
+ startIdleRunnable();
+ } else {
+ stopIdleRunnable();
+ }
+ }
+
+ /**
+ * Set radius in dp of each circle indicator. Default value is {@link Indicator#DEFAULT_RADIUS_DP}.
+ * Note: make sure you set circle Radius, not a Diameter.
+ *
+ * @param radiusDp radius of circle in dp.
+ */
+ public void setRadius(int radiusDp) {
+ if (radiusDp < 0) {
+ radiusDp = 0;
+ }
+
+ int radiusPx = DensityUtils.dpToPx(radiusDp);
+ manager.indicator().setRadius(radiusPx);
+ invalidate();
+ }
+
+ /**
+ * Set radius in px of each circle indicator. Default value is {@link Indicator#DEFAULT_RADIUS_DP}.
+ * Note: make sure you set circle Radius, not a Diameter.
+ *
+ * @param radiusPx radius of circle in px.
+ */
+ public void setRadius(float radiusPx) {
+ if (radiusPx < 0) {
+ radiusPx = 0;
+ }
+
+ manager.indicator().setRadius((int) radiusPx);
+ invalidate();
+ }
+
+ /**
+ * Return radius of each circle indicators in px. If custom radius is not set, return
+ * default value {@link Indicator#DEFAULT_RADIUS_DP}.
+ */
+ public int getRadius() {
+ return manager.indicator().getRadius();
+ }
+
+ /**
+ * Set padding in dp between each circle indicator. Default value is {@link Indicator#DEFAULT_PADDING_DP}.
+ *
+ * @param paddingDp padding between circles in dp.
+ */
+ public void setPadding(int paddingDp) {
+ if (paddingDp < 0) {
+ paddingDp = 0;
+ }
+
+ int paddingPx = DensityUtils.dpToPx(paddingDp);
+ manager.indicator().setPadding(paddingPx);
+ invalidate();
+ }
+
+ /**
+ * Set padding in px between each circle indicator. Default value is {@link Indicator#DEFAULT_PADDING_DP}.
+ *
+ * @param paddingPx padding between circles in px.
+ */
+ public void setPadding(float paddingPx) {
+ if (paddingPx < 0) {
+ paddingPx = 0;
+ }
+
+ manager.indicator().setPadding((int) paddingPx);
+ invalidate();
+ }
+
+ /**
+ * Return padding in px between each circle indicator. If custom padding is not set,
+ * return default value {@link Indicator#DEFAULT_PADDING_DP}.
+ */
+ public int getPadding() {
+ return manager.indicator().getPadding();
+ }
+
+ /**
+ * Set scale factor used in {@link AnimationType#SCALE} animation.
+ * Defines size of unselected indicator circles in comparing to selected one.
+ * Minimum and maximum values are {@link ScaleAnimation#MAX_SCALE_FACTOR} and {@link ScaleAnimation#MIN_SCALE_FACTOR}.
+ * See also {@link ScaleAnimation#DEFAULT_SCALE_FACTOR}.
+ *
+ * @param factor float value in range between 0 and 1.
+ */
+ public void setScaleFactor(float factor) {
+ if (factor > ScaleAnimation.MAX_SCALE_FACTOR) {
+ factor = ScaleAnimation.MAX_SCALE_FACTOR;
+
+ } else if (factor < ScaleAnimation.MIN_SCALE_FACTOR) {
+ factor = ScaleAnimation.MIN_SCALE_FACTOR;
+ }
+
+ manager.indicator().setScaleFactor(factor);
+ }
+
+ /**
+ * Returns scale factor values used in {@link AnimationType#SCALE} animation.
+ * Defines size of unselected indicator circles in comparing to selected one.
+ * Minimum and maximum values are {@link ScaleAnimation#MAX_SCALE_FACTOR} and {@link ScaleAnimation#MIN_SCALE_FACTOR}.
+ * See also {@link ScaleAnimation#DEFAULT_SCALE_FACTOR}.
+ *
+ * @return float value that indicate scale factor.
+ */
+ public float getScaleFactor() {
+ return manager.indicator().getScaleFactor();
+ }
+
+ /**
+ * Set stroke width in px to set while {@link AnimationType#FILL} is selected.
+ * Default value is {@link FillAnimation#DEFAULT_STROKE_DP}
+ *
+ * @param strokePx stroke width in px.
+ */
+ public void setStrokeWidth(float strokePx) {
+ int radiusPx = manager.indicator().getRadius();
+
+ if (strokePx < 0) {
+ strokePx = 0;
+
+ } else if (strokePx > radiusPx) {
+ strokePx = radiusPx;
+ }
+
+ manager.indicator().setStroke((int) strokePx);
+ invalidate();
+ }
+
+ /**
+ * Set stroke width in dp to set while {@link AnimationType#FILL} is selected.
+ * Default value is {@link FillAnimation#DEFAULT_STROKE_DP}
+ *
+ * @param strokeDp stroke width in dp.
+ */
+
+ public void setStrokeWidth(int strokeDp) {
+ int strokePx = DensityUtils.dpToPx(strokeDp);
+ int radiusPx = manager.indicator().getRadius();
+
+ if (strokePx < 0) {
+ strokePx = 0;
+
+ } else if (strokePx > radiusPx) {
+ strokePx = radiusPx;
+ }
+
+ manager.indicator().setStroke(strokePx);
+ invalidate();
+ }
+
+ /**
+ * Return stroke width in px if {@link AnimationType#FILL} is selected, 0 otherwise.
+ */
+ public int getStrokeWidth() {
+ return manager.indicator().getStroke();
+ }
+
+ /**
+ * Set color of selected state to circle indicator. Default color is {@link ColorAnimation#DEFAULT_SELECTED_COLOR}.
+ *
+ * @param color color selected circle.
+ */
+ public void setSelectedColor(int color) {
+ manager.indicator().setSelectedColor(color);
+ invalidate();
+ }
+
+ /**
+ * Return color of selected circle indicator. If custom unselected color
+ * is not set, return default color {@link ColorAnimation#DEFAULT_SELECTED_COLOR}.
+ */
+ public int getSelectedColor() {
+ return manager.indicator().getSelectedColor();
+ }
+
+ /**
+ * Set color of unselected state to each circle indicator. Default color {@link ColorAnimation#DEFAULT_UNSELECTED_COLOR}.
+ *
+ * @param color color of each unselected circle.
+ */
+ public void setUnselectedColor(int color) {
+ manager.indicator().setUnselectedColor(color);
+ invalidate();
+ }
+
+ /**
+ * Return color of unselected state of each circle indicator. If custom unselected color
+ * is not set, return default color {@link ColorAnimation#DEFAULT_UNSELECTED_COLOR}.
+ */
+ public int getUnselectedColor() {
+ return manager.indicator().getUnselectedColor();
+ }
+
+ /**
+ * Automatically hide (View.INVISIBLE) PageIndicatorView while indicator count is <= 1.
+ * Default is true.
+ *
+ * @param autoVisibility auto hide indicators.
+ */
+ public void setAutoVisibility(boolean autoVisibility) {
+ if (!autoVisibility) {
+ setVisibility(VISIBLE);
+ }
+
+ manager.indicator().setAutoVisibility(autoVisibility);
+ updateVisibility();
+ }
+
+ /**
+ * Set orientation for indicator, one of HORIZONTAL or VERTICAL.
+ * Default is HORIZONTAL.
+ *
+ * @param orientation an orientation to display page indicators.
+ */
+ public void setOrientation(@Nullable Orientation orientation) {
+ if (orientation != null) {
+ manager.indicator().setOrientation(orientation);
+ requestLayout();
+ }
+ }
+
+ /**
+ * Set animation duration time in millisecond. Default animation duration time is {@link BaseAnimation#DEFAULT_ANIMATION_TIME}.
+ * (Won't affect on anything unless {@link #setAnimationType(AnimationType type)} is specified
+ * and {@link #setInteractiveAnimation(boolean isInteractive)} is false).
+ *
+ * @param duration animation duration time.
+ */
+ public void setAnimationDuration(long duration) {
+ manager.indicator().setAnimationDuration(duration);
+ }
+
+ /**
+ * Sets time in millis after which {@link ViewPager} is considered idle.
+ * If {@link Indicator#fadeOnIdle} is true, {@link PageIndicatorView} will
+ * fade away after entering idle state and appear when it is left.
+ *
+ * @param duration time in millis after which {@link ViewPager} is considered idle
+ */
+ public void setIdleDuration(long duration) {
+ manager.indicator().setIdleDuration(duration);
+ if (manager.indicator().isFadeOnIdle()) {
+ startIdleRunnable();
+ } else {
+ stopIdleRunnable();
+ }
+ }
+
+ /**
+ * Return animation duration time in milliseconds. If custom duration is not set,
+ * return default duration time {@link BaseAnimation#DEFAULT_ANIMATION_TIME}.
+ */
+ public long getAnimationDuration() {
+ return manager.indicator().getAnimationDuration();
+ }
+
+ /**
+ * Set animation type to perform while selecting new circle indicator.
+ * Default animation type is {@link AnimationType#NONE}.
+ *
+ * @param type type of animation, one of {@link AnimationType}
+ */
+ public void setAnimationType(@Nullable AnimationType type) {
+ manager.onValueUpdated(null);
+
+ if (type != null) {
+ manager.indicator().setAnimationType(type);
+ } else {
+ manager.indicator().setAnimationType(AnimationType.NONE);
+ }
+ invalidate();
+ }
+
+ /**
+ * Interactive animation will animate indicator smoothly
+ * from position to position based on user's current swipe progress.
+ * (Won't affect on anything unless {@link #setViewPager(ViewPager)} is specified).
+ *
+ * @param isInteractive value of animation to be interactive or not.
+ */
+ public void setInteractiveAnimation(boolean isInteractive) {
+ manager.indicator().setInteractiveAnimation(isInteractive);
+ this.isInteractionEnabled = isInteractive;
+ }
+
+ /**
+ * Set {@link ViewPager} to add {@link ViewPager.OnPageChangeListener} and automatically
+ * handle selecting new indicators (and interactive animation effect if it is enabled).
+ *
+ * @param pager instance of {@link ViewPager} to work with
+ */
+ @SuppressLint("ClickableViewAccessibility")
+ public void setViewPager(@Nullable ViewPager pager) {
+ releaseViewPager();
+ if (pager == null) {
+ return;
+ }
+
+ viewPager = pager;
+ viewPager.addOnPageChangeListener(this);
+ viewPager.addOnAdapterChangeListener(this);
+ viewPager.setOnTouchListener(this);
+ manager.indicator().setViewPagerId(viewPager.getId());
+
+ setDynamicCount(manager.indicator().isDynamicCount());
+ updateState();
+ }
+
+ /**
+ * Release {@link ViewPager} and stop handling events of {@link ViewPager.OnPageChangeListener}.
+ */
+ public void releaseViewPager() {
+ if (viewPager != null) {
+ viewPager.removeOnPageChangeListener(this);
+ viewPager.removeOnAdapterChangeListener(this);
+ viewPager = null;
+ }
+ }
+
+ /**
+ * Specify to display PageIndicatorView with Right to left layout or not.
+ * One of {@link RtlMode}: Off (Left to right), On (Right to left)
+ * or Auto (handle this mode automatically based on users language preferences).
+ * Default is Off.
+ *
+ * @param mode instance of {@link RtlMode}
+ */
+ public void setRtlMode(@Nullable RtlMode mode) {
+ Indicator indicator = manager.indicator();
+ if (mode == null) {
+ indicator.setRtlMode(RtlMode.Off);
+ } else {
+ indicator.setRtlMode(mode);
+ }
+
+ if (viewPager == null) {
+ return;
+ }
+
+ int selectedPosition = indicator.getSelectedPosition();
+ int position = selectedPosition;
+
+ if (isRtl()) {
+ position = (indicator.getCount() - 1) - selectedPosition;
+
+ } else if (viewPager != null) {
+ position = viewPager.getCurrentItem();
+ }
+
+ indicator.setLastSelectedPosition(position);
+ indicator.setSelectingPosition(position);
+ indicator.setSelectedPosition(position);
+ invalidate();
+ }
+
+ /**
+ * Return position of currently selected circle indicator.
+ */
+ public int getSelection() {
+ return manager.indicator().getSelectedPosition();
+ }
+
+ /**
+ * Set specific circle indicator position to be selected. If position < or > total count,
+ * accordingly first or last circle indicator will be selected.
+ *
+ * @param position position of indicator to select.
+ */
+ public void setSelection(int position) {
+ Indicator indicator = manager.indicator();
+ position = adjustPosition(position);
+
+ if (position == indicator.getSelectedPosition() || position == indicator.getSelectingPosition()) {
+ return;
+ }
+
+ indicator.setInteractiveAnimation(false);
+ indicator.setLastSelectedPosition(indicator.getSelectedPosition());
+ indicator.setSelectingPosition(position);
+ indicator.setSelectedPosition(position);
+ manager.animate().basic();
+ }
+
+ /**
+ * Set specific circle indicator position to be selected without any kind of animation. If position < or > total count,
+ * accordingly first or last circle indicator will be selected.
+ *
+ * @param position position of indicator to select.
+ */
+ public void setSelected(int position) {
+ Indicator indicator = manager.indicator();
+ AnimationType animationType = indicator.getAnimationType();
+ indicator.setAnimationType(AnimationType.NONE);
+
+ setSelection(position);
+ indicator.setAnimationType(animationType);
+ }
+
+ /**
+ * Clears selection of all indicators
+ */
+ public void clearSelection() {
+ //TODO check
+ Indicator indicator = manager.indicator();
+ indicator.setInteractiveAnimation(false);
+ indicator.setLastSelectedPosition(Indicator.COUNT_NONE);
+ indicator.setSelectingPosition(Indicator.COUNT_NONE);
+ indicator.setSelectedPosition(Indicator.COUNT_NONE);
+ manager.animate().basic();
+ }
+
+ /**
+ * Set progress value in range [0 - 1] to specify state of animation while selecting new circle indicator.
+ *
+ * @param selectingPosition selecting position with specific progress value.
+ * @param progress float value of progress.
+ */
+ public void setProgress(int selectingPosition, float progress) {
+ Indicator indicator = manager.indicator();
+ if (!indicator.isInteractiveAnimation()) {
+ return;
+ }
+
+ int count = indicator.getCount();
+ if (count <= 0 || selectingPosition < 0) {
+ selectingPosition = 0;
+
+ } else if (selectingPosition > count - 1) {
+ selectingPosition = count - 1;
+ }
+
+ if (progress < 0) {
+ progress = 0;
+
+ } else if (progress > 1) {
+ progress = 1;
+ }
+
+ if (progress == 1) {
+ indicator.setLastSelectedPosition(indicator.getSelectedPosition());
+ indicator.setSelectedPosition(selectingPosition);
+ }
+
+ indicator.setSelectingPosition(selectingPosition);
+ manager.animate().interactive(progress);
+ }
+
+ public void setClickListener(@Nullable DrawController.ClickListener listener) {
+ manager.drawer().setClickListener(listener);
+ }
+
+ private void init(@Nullable AttributeSet attrs) {
+ setupId();
+ initIndicatorManager(attrs);
+
+ if (manager.indicator().isFadeOnIdle()) {
+ startIdleRunnable();
+ }
+ }
+
+ private void setupId() {
+ if (getId() == NO_ID) {
+ setId(IdUtils.generateViewId());
+ }
+ }
+
+ private void initIndicatorManager(@Nullable AttributeSet attrs) {
+ manager = new IndicatorManager(this);
+ manager.drawer().initAttributes(getContext(), attrs);
+
+ Indicator indicator = manager.indicator();
+ indicator.setPaddingLeft(getPaddingLeft());
+ indicator.setPaddingTop(getPaddingTop());
+ indicator.setPaddingRight(getPaddingRight());
+ indicator.setPaddingBottom(getPaddingBottom());
+ isInteractionEnabled = indicator.isInteractiveAnimation();
+ }
+
+ private void registerSetObserver() {
+ if (setObserver != null || viewPager == null || viewPager.getAdapter() == null) {
+ return;
+ }
+
+ setObserver = new DataSetObserver() {
+ @Override
+ public void onChanged() {
+ updateState();
+ }
+ };
+
+ try {
+ viewPager.getAdapter().registerDataSetObserver(setObserver);
+ } catch (IllegalStateException e) {
+ e.printStackTrace();
+ }
+ }
+
+ private void unRegisterSetObserver() {
+ if (setObserver == null || viewPager == null || viewPager.getAdapter() == null) {
+ return;
+ }
+
+ try {
+ viewPager.getAdapter().unregisterDataSetObserver(setObserver);
+ setObserver = null;
+ } catch (IllegalStateException e) {
+ e.printStackTrace();
+ }
+ }
+
+ private void updateState() {
+ if (viewPager == null || viewPager.getAdapter() == null) {
+ return;
+ }
+
+ int count = viewPager.getAdapter().getCount();
+ int selectedPos = isRtl() ? (count - 1) - viewPager.getCurrentItem() : viewPager.getCurrentItem();
+
+ manager.indicator().setSelectedPosition(selectedPos);
+ manager.indicator().setSelectingPosition(selectedPos);
+ manager.indicator().setLastSelectedPosition(selectedPos);
+ manager.indicator().setCount(count);
+ manager.animate().end();
+
+ updateVisibility();
+ requestLayout();
+ }
+
+ private void updateVisibility() {
+ if (!manager.indicator().isAutoVisibility()) {
+ return;
+ }
+
+ int count = manager.indicator().getCount();
+ int visibility = getVisibility();
+
+ if (visibility != VISIBLE && count > Indicator.MIN_COUNT) {
+ setVisibility(VISIBLE);
+
+ } else if (visibility != INVISIBLE && count <= Indicator.MIN_COUNT) {
+ setVisibility(View.INVISIBLE);
+ }
+ }
+
+ private void onPageSelect(int position) {
+ Indicator indicator = manager.indicator();
+ boolean canSelectIndicator = isViewMeasured();
+ int count = indicator.getCount();
+
+ if (canSelectIndicator) {
+ if (isRtl()) {
+ position = (count - 1) - position;
+ }
+
+ setSelection(position);
+ }
+ }
+
+ private void onPageScroll(int position, float positionOffset) {
+ Indicator indicator = manager.indicator();
+ AnimationType animationType = indicator.getAnimationType();
+ boolean interactiveAnimation = indicator.isInteractiveAnimation();
+ boolean canSelectIndicator = isViewMeasured() && interactiveAnimation && animationType != AnimationType.NONE;
+
+ if (!canSelectIndicator) {
+ return;
+ }
+
+ Pair progressPair = CoordinatesUtils.getProgress(indicator, position, positionOffset, isRtl());
+ int selectingPosition = progressPair.first;
+ float selectingProgress = progressPair.second;
+ setProgress(selectingPosition, selectingProgress);
+ }
+
+ private boolean isRtl() {
+ switch (manager.indicator().getRtlMode()) {
+ case On:
+ return true;
+
+ case Off:
+ return false;
+
+ case Auto:
+ return TextUtilsCompat.getLayoutDirectionFromLocale(getContext().getResources().getConfiguration().locale) == ViewCompat.LAYOUT_DIRECTION_RTL;
+ }
+
+ return false;
+ }
+
+ private boolean isViewMeasured() {
+ return getMeasuredHeight() != 0 || getMeasuredWidth() != 0;
+ }
+
+ private void findViewPager(@Nullable ViewParent viewParent) {
+ boolean isValidParent = viewParent != null &&
+ viewParent instanceof ViewGroup &&
+ ((ViewGroup) viewParent).getChildCount() > 0;
+
+ if (!isValidParent) {
+ return;
+ }
+
+ int viewPagerId = manager.indicator().getViewPagerId();
+ ViewPager viewPager = findViewPager((ViewGroup) viewParent, viewPagerId);
+
+ if (viewPager != null) {
+ setViewPager(viewPager);
+ } else {
+ findViewPager(viewParent.getParent());
+ }
+ }
+
+ @Nullable
+ private ViewPager findViewPager(@NonNull ViewGroup viewGroup, int id) {
+ if (viewGroup.getChildCount() <= 0) {
+ return null;
+ }
+
+ View view = viewGroup.findViewById(id);
+ if (view != null && view instanceof ViewPager) {
+ return (ViewPager) view;
+ } else {
+ return null;
+ }
+ }
+
+ private int adjustPosition(int position) {
+ Indicator indicator = manager.indicator();
+ int count = indicator.getCount();
+ int lastPosition = count - 1;
+
+ if (position < 0) {
+ position = 0;
+
+ } else if (position > lastPosition) {
+ position = lastPosition;
+ }
+
+ return position;
+ }
+
+ private void displayWithAnimation() {
+ animate().cancel();
+ animate().alpha(1.0f).setDuration(Indicator.IDLE_ANIMATION_DURATION);
+ }
+
+ private void hideWithAnimation() {
+ animate().cancel();
+ animate().alpha(0f).setDuration(Indicator.IDLE_ANIMATION_DURATION);
+ }
+
+ private void startIdleRunnable() {
+ HANDLER.removeCallbacks(idleRunnable);
+ HANDLER.postDelayed(idleRunnable, manager.indicator().getIdleDuration());
+ }
+
+ private void stopIdleRunnable() {
+ HANDLER.removeCallbacks(idleRunnable);
+ displayWithAnimation();
+ }
+
+ private Runnable idleRunnable = new Runnable() {
+ @Override
+ public void run() {
+ manager.indicator().setIdle(true);
+ hideWithAnimation();
+ }
+ };
+}
diff --git a/pageindicatorview/src/main/java/com/rd/animation/AnimationManager.java b/pageindicatorview/src/main/java/com/rd/animation/AnimationManager.java
new file mode 100644
index 0000000..2ec0b3a
--- /dev/null
+++ b/pageindicatorview/src/main/java/com/rd/animation/AnimationManager.java
@@ -0,0 +1,34 @@
+package com.rd.animation;
+
+import androidx.annotation.NonNull;
+import com.rd.animation.controller.AnimationController;
+import com.rd.animation.controller.ValueController;
+import com.rd.draw.data.Indicator;
+
+public class AnimationManager {
+
+ private AnimationController animationController;
+
+ public AnimationManager(@NonNull Indicator indicator, @NonNull ValueController.UpdateListener listener) {
+ this.animationController = new AnimationController(indicator, listener);
+ }
+
+ public void basic() {
+ if (animationController != null) {
+ animationController.end();
+ animationController.basic();
+ }
+ }
+
+ public void interactive(float progress) {
+ if (animationController != null) {
+ animationController.interactive(progress);
+ }
+ }
+
+ public void end() {
+ if (animationController != null) {
+ animationController.end();
+ }
+ }
+}
diff --git a/pageindicatorview/src/main/java/com/rd/animation/controller/AnimationController.java b/pageindicatorview/src/main/java/com/rd/animation/controller/AnimationController.java
new file mode 100644
index 0000000..c2eb32b
--- /dev/null
+++ b/pageindicatorview/src/main/java/com/rd/animation/controller/AnimationController.java
@@ -0,0 +1,297 @@
+package com.rd.animation.controller;
+
+import androidx.annotation.NonNull;
+import com.rd.animation.type.AnimationType;
+import com.rd.animation.type.BaseAnimation;
+import com.rd.draw.data.Indicator;
+import com.rd.draw.data.Orientation;
+import com.rd.utils.CoordinatesUtils;
+
+public class AnimationController {
+
+ private ValueController valueController;
+ private ValueController.UpdateListener listener;
+
+ private BaseAnimation runningAnimation;
+ private Indicator indicator;
+
+ private float progress;
+ private boolean isInteractive;
+
+ public AnimationController(@NonNull Indicator indicator, @NonNull ValueController.UpdateListener listener) {
+ this.valueController = new ValueController(listener);
+ this.listener = listener;
+ this.indicator = indicator;
+ }
+
+ public void interactive(float progress) {
+ this.isInteractive = true;
+ this.progress = progress;
+ animate();
+ }
+
+ public void basic() {
+ this.isInteractive = false;
+ this.progress = 0;
+ animate();
+ }
+
+ public void end() {
+ if (runningAnimation != null) {
+ runningAnimation.end();
+ }
+ }
+
+ private void animate() {
+ AnimationType animationType = indicator.getAnimationType();
+ switch (animationType) {
+ case NONE:
+ listener.onValueUpdated(null);
+ break;
+
+ case COLOR:
+ colorAnimation();
+ break;
+
+ case SCALE:
+ scaleAnimation();
+ break;
+
+ case WORM:
+ wormAnimation();
+ break;
+
+ case FILL:
+ fillAnimation();
+ break;
+
+ case SLIDE:
+ slideAnimation();
+ break;
+
+ case THIN_WORM:
+ thinWormAnimation();
+ break;
+
+ case DROP:
+ dropAnimation();
+ break;
+
+ case SWAP:
+ swapAnimation();
+ break;
+
+ case SCALE_DOWN:
+ scaleDownAnimation();
+ break;
+ }
+ }
+
+ private void colorAnimation() {
+ int selectedColor = indicator.getSelectedColor();
+ int unselectedColor = indicator.getUnselectedColor();
+ long animationDuration = indicator.getAnimationDuration();
+
+ BaseAnimation animation = valueController
+ .color()
+ .with(unselectedColor, selectedColor)
+ .duration(animationDuration);
+
+ if (isInteractive) {
+ animation.progress(progress);
+ } else {
+ animation.start();
+ }
+
+ runningAnimation = animation;
+ }
+
+ private void scaleAnimation() {
+ int selectedColor = indicator.getSelectedColor();
+ int unselectedColor = indicator.getUnselectedColor();
+ int radiusPx = indicator.getRadius();
+ float scaleFactor = indicator.getScaleFactor();
+ long animationDuration = indicator.getAnimationDuration();
+
+ BaseAnimation animation = valueController
+ .scale()
+ .with(unselectedColor, selectedColor, radiusPx, scaleFactor)
+ .duration(animationDuration);
+
+ if (isInteractive) {
+ animation.progress(progress);
+ } else {
+ animation.start();
+ }
+
+ runningAnimation = animation;
+ }
+
+ private void wormAnimation() {
+ int fromPosition = indicator.isInteractiveAnimation() ? indicator.getSelectedPosition() : indicator.getLastSelectedPosition();
+ int toPosition = indicator.isInteractiveAnimation() ? indicator.getSelectingPosition() : indicator.getSelectedPosition();
+
+ int from = CoordinatesUtils.getCoordinate(indicator, fromPosition);
+ int to = CoordinatesUtils.getCoordinate(indicator, toPosition);
+ boolean isRightSide = toPosition > fromPosition;
+
+ int radiusPx = indicator.getRadius();
+ long animationDuration = indicator.getAnimationDuration();
+
+ BaseAnimation animation = valueController
+ .worm()
+ .with(from, to, radiusPx, isRightSide)
+ .duration(animationDuration);
+
+ if (isInteractive) {
+ animation.progress(progress);
+ } else {
+ animation.start();
+ }
+
+ runningAnimation = animation;
+ }
+
+ private void slideAnimation() {
+ int fromPosition = indicator.isInteractiveAnimation() ? indicator.getSelectedPosition() : indicator.getLastSelectedPosition();
+ int toPosition = indicator.isInteractiveAnimation() ? indicator.getSelectingPosition() : indicator.getSelectedPosition();
+
+ int from = CoordinatesUtils.getCoordinate(indicator, fromPosition);
+ int to = CoordinatesUtils.getCoordinate(indicator, toPosition);
+ long animationDuration = indicator.getAnimationDuration();
+
+ BaseAnimation animation = valueController
+ .slide()
+ .with(from, to)
+ .duration(animationDuration);
+
+ if (isInteractive) {
+ animation.progress(progress);
+ } else {
+ animation.start();
+ }
+
+ runningAnimation = animation;
+ }
+
+ private void fillAnimation() {
+ int selectedColor = indicator.getSelectedColor();
+ int unselectedColor = indicator.getUnselectedColor();
+ int radiusPx = indicator.getRadius();
+ int strokePx = indicator.getStroke();
+ long animationDuration = indicator.getAnimationDuration();
+
+ BaseAnimation animation = valueController
+ .fill()
+ .with(unselectedColor, selectedColor, radiusPx, strokePx)
+ .duration(animationDuration);
+
+ if (isInteractive) {
+ animation.progress(progress);
+ } else {
+ animation.start();
+ }
+
+ runningAnimation = animation;
+ }
+
+ private void thinWormAnimation() {
+ int fromPosition = indicator.isInteractiveAnimation() ? indicator.getSelectedPosition() : indicator.getLastSelectedPosition();
+ int toPosition = indicator.isInteractiveAnimation() ? indicator.getSelectingPosition() : indicator.getSelectedPosition();
+
+ int from = CoordinatesUtils.getCoordinate(indicator, fromPosition);
+ int to = CoordinatesUtils.getCoordinate(indicator, toPosition);
+ boolean isRightSide = toPosition > fromPosition;
+
+ int radiusPx = indicator.getRadius();
+ long animationDuration = indicator.getAnimationDuration();
+
+ BaseAnimation animation = valueController
+ .thinWorm()
+ .with(from, to, radiusPx, isRightSide)
+ .duration(animationDuration);
+
+ if (isInteractive) {
+ animation.progress(progress);
+ } else {
+ animation.start();
+ }
+
+ runningAnimation = animation;
+ }
+
+ private void dropAnimation() {
+ int fromPosition = indicator.isInteractiveAnimation() ? indicator.getSelectedPosition() : indicator.getLastSelectedPosition();
+ int toPosition = indicator.isInteractiveAnimation() ? indicator.getSelectingPosition() : indicator.getSelectedPosition();
+
+ int widthFrom = CoordinatesUtils.getCoordinate(indicator, fromPosition);
+ int widthTo = CoordinatesUtils.getCoordinate(indicator, toPosition);
+
+ int paddingTop = indicator.getPaddingTop();
+ int paddingLeft = indicator.getPaddingLeft();
+ int padding = indicator.getOrientation() == Orientation.HORIZONTAL ? paddingTop : paddingLeft;
+
+ int radius = indicator.getRadius();
+ int heightFrom = radius * 3 + padding;
+ int heightTo = radius + padding;
+
+ long animationDuration = indicator.getAnimationDuration();
+
+ BaseAnimation animation = valueController
+ .drop()
+ .duration(animationDuration)
+ .with(widthFrom, widthTo, heightFrom, heightTo, radius);
+
+ if (isInteractive) {
+ animation.progress(progress);
+ } else {
+ animation.start();
+ }
+
+ runningAnimation = animation;
+ }
+
+ private void swapAnimation() {
+ int fromPosition = indicator.isInteractiveAnimation() ? indicator.getSelectedPosition() : indicator.getLastSelectedPosition();
+ int toPosition = indicator.isInteractiveAnimation() ? indicator.getSelectingPosition() : indicator.getSelectedPosition();
+
+ int from = CoordinatesUtils.getCoordinate(indicator, fromPosition);
+ int to = CoordinatesUtils.getCoordinate(indicator, toPosition);
+ long animationDuration = indicator.getAnimationDuration();
+
+ BaseAnimation animation = valueController
+ .swap()
+ .with(from, to)
+ .duration(animationDuration);
+
+ if (isInteractive) {
+ animation.progress(progress);
+ } else {
+ animation.start();
+ }
+
+ runningAnimation = animation;
+ }
+
+ private void scaleDownAnimation() {
+ int selectedColor = indicator.getSelectedColor();
+ int unselectedColor = indicator.getUnselectedColor();
+ int radiusPx = indicator.getRadius();
+ float scaleFactor = indicator.getScaleFactor();
+ long animationDuration = indicator.getAnimationDuration();
+
+ BaseAnimation animation = valueController
+ .scaleDown()
+ .with(unselectedColor, selectedColor, radiusPx, scaleFactor)
+ .duration(animationDuration);
+
+ if (isInteractive) {
+ animation.progress(progress);
+ } else {
+ animation.start();
+ }
+
+ runningAnimation = animation;
+ }
+}
+
diff --git a/pageindicatorview/src/main/java/com/rd/animation/controller/ValueController.java b/pageindicatorview/src/main/java/com/rd/animation/controller/ValueController.java
new file mode 100644
index 0000000..4e70ce8
--- /dev/null
+++ b/pageindicatorview/src/main/java/com/rd/animation/controller/ValueController.java
@@ -0,0 +1,110 @@
+package com.rd.animation.controller;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import com.rd.animation.data.Value;
+import com.rd.animation.type.*;
+
+public class ValueController {
+
+ private ColorAnimation colorAnimation;
+ private ScaleAnimation scaleAnimation;
+ private WormAnimation wormAnimation;
+ private SlideAnimation slideAnimation;
+ private FillAnimation fillAnimation;
+ private ThinWormAnimation thinWormAnimation;
+ private DropAnimation dropAnimation;
+ private SwapAnimation swapAnimation;
+ private ScaleDownAnimation scaleDownAnimation;
+
+ private UpdateListener updateListener;
+
+ public interface UpdateListener {
+ void onValueUpdated(@Nullable Value value);
+ }
+
+ public ValueController(@Nullable UpdateListener listener) {
+ updateListener = listener;
+ }
+
+ @NonNull
+ public ColorAnimation color() {
+ if (colorAnimation == null) {
+ colorAnimation = new ColorAnimation(updateListener);
+ }
+
+ return colorAnimation;
+ }
+
+ @NonNull
+ public ScaleAnimation scale() {
+ if (scaleAnimation == null) {
+ scaleAnimation = new ScaleAnimation(updateListener);
+ }
+
+ return scaleAnimation;
+ }
+
+ @NonNull
+ public WormAnimation worm() {
+ if (wormAnimation == null) {
+ wormAnimation = new WormAnimation(updateListener);
+ }
+
+ return wormAnimation;
+ }
+
+ @NonNull
+ public SlideAnimation slide() {
+ if (slideAnimation == null) {
+ slideAnimation = new SlideAnimation(updateListener);
+ }
+
+ return slideAnimation;
+ }
+
+ @NonNull
+ public FillAnimation fill() {
+ if (fillAnimation == null) {
+ fillAnimation = new FillAnimation(updateListener);
+ }
+
+ return fillAnimation;
+ }
+
+ @NonNull
+ public ThinWormAnimation thinWorm() {
+ if (thinWormAnimation == null) {
+ thinWormAnimation = new ThinWormAnimation(updateListener);
+ }
+
+ return thinWormAnimation;
+ }
+
+ @NonNull
+ public DropAnimation drop() {
+ if (dropAnimation == null) {
+ dropAnimation = new DropAnimation(updateListener);
+ }
+
+ return dropAnimation;
+ }
+
+ @NonNull
+ public SwapAnimation swap() {
+ if (swapAnimation == null) {
+ swapAnimation = new SwapAnimation(updateListener);
+ }
+
+ return swapAnimation;
+ }
+
+ @NonNull
+ public ScaleDownAnimation scaleDown() {
+ if (scaleDownAnimation == null) {
+ scaleDownAnimation = new ScaleDownAnimation(updateListener);
+ }
+
+ return scaleDownAnimation;
+ }
+}
diff --git a/pageindicatorview/src/main/java/com/rd/animation/data/AnimationValue.java b/pageindicatorview/src/main/java/com/rd/animation/data/AnimationValue.java
new file mode 100644
index 0000000..1514211
--- /dev/null
+++ b/pageindicatorview/src/main/java/com/rd/animation/data/AnimationValue.java
@@ -0,0 +1,71 @@
+package com.rd.animation.data;
+
+import androidx.annotation.NonNull;
+import com.rd.animation.data.type.*;
+
+public class AnimationValue {
+
+ private ColorAnimationValue colorAnimationValue;
+ private ScaleAnimationValue scaleAnimationValue;
+ private WormAnimationValue wormAnimationValue;
+ private FillAnimationValue fillAnimationValue;
+ private ThinWormAnimationValue thinWormAnimationValue;
+ private DropAnimationValue dropAnimationValue;
+ private SwapAnimationValue swapAnimationValue;
+
+ @NonNull
+ public ColorAnimationValue getColorAnimationValue() {
+ if (colorAnimationValue == null) {
+ colorAnimationValue = new ColorAnimationValue();
+ }
+ return colorAnimationValue;
+ }
+
+ @NonNull
+ public ScaleAnimationValue getScaleAnimationValue() {
+ if (scaleAnimationValue == null) {
+ scaleAnimationValue = new ScaleAnimationValue();
+ }
+ return scaleAnimationValue;
+ }
+
+ @NonNull
+ public WormAnimationValue getWormAnimationValue() {
+ if (wormAnimationValue == null) {
+ wormAnimationValue = new WormAnimationValue();
+ }
+ return wormAnimationValue;
+ }
+
+ @NonNull
+ public FillAnimationValue getFillAnimationValue() {
+ if (fillAnimationValue == null) {
+ fillAnimationValue = new FillAnimationValue();
+ }
+ return fillAnimationValue;
+ }
+
+ @NonNull
+ public ThinWormAnimationValue getThinWormAnimationValue() {
+ if (thinWormAnimationValue == null) {
+ thinWormAnimationValue = new ThinWormAnimationValue();
+ }
+ return thinWormAnimationValue;
+ }
+
+ @NonNull
+ public DropAnimationValue getDropAnimationValue() {
+ if (dropAnimationValue == null) {
+ dropAnimationValue = new DropAnimationValue();
+ }
+ return dropAnimationValue;
+ }
+
+ @NonNull
+ public SwapAnimationValue getSwapAnimationValue() {
+ if (swapAnimationValue == null) {
+ swapAnimationValue = new SwapAnimationValue();
+ }
+ return swapAnimationValue;
+ }
+}
diff --git a/pageindicatorview/src/main/java/com/rd/animation/data/Value.java b/pageindicatorview/src/main/java/com/rd/animation/data/Value.java
new file mode 100644
index 0000000..4b83aa5
--- /dev/null
+++ b/pageindicatorview/src/main/java/com/rd/animation/data/Value.java
@@ -0,0 +1,3 @@
+package com.rd.animation.data;
+
+public interface Value {/*empty*/}
diff --git a/pageindicatorview/src/main/java/com/rd/animation/data/type/ColorAnimationValue.java b/pageindicatorview/src/main/java/com/rd/animation/data/type/ColorAnimationValue.java
new file mode 100644
index 0000000..547e987
--- /dev/null
+++ b/pageindicatorview/src/main/java/com/rd/animation/data/type/ColorAnimationValue.java
@@ -0,0 +1,25 @@
+package com.rd.animation.data.type;
+
+import com.rd.animation.data.Value;
+
+public class ColorAnimationValue implements Value {
+
+ private int color;
+ private int colorReverse;
+
+ public int getColor() {
+ return color;
+ }
+
+ public void setColor(int color) {
+ this.color = color;
+ }
+
+ public int getColorReverse() {
+ return colorReverse;
+ }
+
+ public void setColorReverse(int colorReverse) {
+ this.colorReverse = colorReverse;
+ }
+}
diff --git a/pageindicatorview/src/main/java/com/rd/animation/data/type/DropAnimationValue.java b/pageindicatorview/src/main/java/com/rd/animation/data/type/DropAnimationValue.java
new file mode 100644
index 0000000..1279055
--- /dev/null
+++ b/pageindicatorview/src/main/java/com/rd/animation/data/type/DropAnimationValue.java
@@ -0,0 +1,34 @@
+package com.rd.animation.data.type;
+
+import com.rd.animation.data.Value;
+
+public class DropAnimationValue implements Value {
+
+ private int width;
+ private int height;
+ private int radius;
+
+ public int getWidth() {
+ return width;
+ }
+
+ public void setWidth(int width) {
+ this.width = width;
+ }
+
+ public int getHeight() {
+ return height;
+ }
+
+ public void setHeight(int height) {
+ this.height = height;
+ }
+
+ public int getRadius() {
+ return radius;
+ }
+
+ public void setRadius(int radius) {
+ this.radius = radius;
+ }
+}
diff --git a/pageindicatorview/src/main/java/com/rd/animation/data/type/FillAnimationValue.java b/pageindicatorview/src/main/java/com/rd/animation/data/type/FillAnimationValue.java
new file mode 100644
index 0000000..b9bf10b
--- /dev/null
+++ b/pageindicatorview/src/main/java/com/rd/animation/data/type/FillAnimationValue.java
@@ -0,0 +1,44 @@
+package com.rd.animation.data.type;
+
+import com.rd.animation.data.Value;
+
+public class FillAnimationValue extends ColorAnimationValue implements Value {
+
+ private int radius;
+ private int radiusReverse;
+
+ private int stroke;
+ private int strokeReverse;
+
+ public int getRadius() {
+ return radius;
+ }
+
+ public void setRadius(int radius) {
+ this.radius = radius;
+ }
+
+ public int getRadiusReverse() {
+ return radiusReverse;
+ }
+
+ public void setRadiusReverse(int radiusReverse) {
+ this.radiusReverse = radiusReverse;
+ }
+
+ public int getStroke() {
+ return stroke;
+ }
+
+ public void setStroke(int stroke) {
+ this.stroke = stroke;
+ }
+
+ public int getStrokeReverse() {
+ return strokeReverse;
+ }
+
+ public void setStrokeReverse(int strokeReverse) {
+ this.strokeReverse = strokeReverse;
+ }
+}
diff --git a/pageindicatorview/src/main/java/com/rd/animation/data/type/ScaleAnimationValue.java b/pageindicatorview/src/main/java/com/rd/animation/data/type/ScaleAnimationValue.java
new file mode 100644
index 0000000..b93facb
--- /dev/null
+++ b/pageindicatorview/src/main/java/com/rd/animation/data/type/ScaleAnimationValue.java
@@ -0,0 +1,25 @@
+package com.rd.animation.data.type;
+
+import com.rd.animation.data.Value;
+
+public class ScaleAnimationValue extends ColorAnimationValue implements Value {
+
+ private int radius;
+ private int radiusReverse;
+
+ public int getRadius() {
+ return radius;
+ }
+
+ public void setRadius(int radius) {
+ this.radius = radius;
+ }
+
+ public int getRadiusReverse() {
+ return radiusReverse;
+ }
+
+ public void setRadiusReverse(int radiusReverse) {
+ this.radiusReverse = radiusReverse;
+ }
+}
diff --git a/pageindicatorview/src/main/java/com/rd/animation/data/type/SlideAnimationValue.java b/pageindicatorview/src/main/java/com/rd/animation/data/type/SlideAnimationValue.java
new file mode 100644
index 0000000..a38dda1
--- /dev/null
+++ b/pageindicatorview/src/main/java/com/rd/animation/data/type/SlideAnimationValue.java
@@ -0,0 +1,16 @@
+package com.rd.animation.data.type;
+
+import com.rd.animation.data.Value;
+
+public class SlideAnimationValue implements Value {
+
+ private int coordinate;
+
+ public int getCoordinate() {
+ return coordinate;
+ }
+
+ public void setCoordinate(int coordinate) {
+ this.coordinate = coordinate;
+ }
+}
diff --git a/pageindicatorview/src/main/java/com/rd/animation/data/type/SwapAnimationValue.java b/pageindicatorview/src/main/java/com/rd/animation/data/type/SwapAnimationValue.java
new file mode 100644
index 0000000..76ac312
--- /dev/null
+++ b/pageindicatorview/src/main/java/com/rd/animation/data/type/SwapAnimationValue.java
@@ -0,0 +1,25 @@
+package com.rd.animation.data.type;
+
+import com.rd.animation.data.Value;
+
+public class SwapAnimationValue implements Value {
+
+ private int coordinate;
+ private int coordinateReverse;
+
+ public int getCoordinate() {
+ return coordinate;
+ }
+
+ public void setCoordinate(int coordinate) {
+ this.coordinate = coordinate;
+ }
+
+ public int getCoordinateReverse() {
+ return coordinateReverse;
+ }
+
+ public void setCoordinateReverse(int coordinateReverse) {
+ this.coordinateReverse = coordinateReverse;
+ }
+}
diff --git a/pageindicatorview/src/main/java/com/rd/animation/data/type/ThinWormAnimationValue.java b/pageindicatorview/src/main/java/com/rd/animation/data/type/ThinWormAnimationValue.java
new file mode 100644
index 0000000..7a0ba80
--- /dev/null
+++ b/pageindicatorview/src/main/java/com/rd/animation/data/type/ThinWormAnimationValue.java
@@ -0,0 +1,16 @@
+package com.rd.animation.data.type;
+
+import com.rd.animation.data.Value;
+
+public class ThinWormAnimationValue extends WormAnimationValue implements Value {
+
+ private int height;
+
+ public int getHeight() {
+ return height;
+ }
+
+ public void setHeight(int height) {
+ this.height = height;
+ }
+}
diff --git a/pageindicatorview/src/main/java/com/rd/animation/data/type/WormAnimationValue.java b/pageindicatorview/src/main/java/com/rd/animation/data/type/WormAnimationValue.java
new file mode 100644
index 0000000..6a1c3e2
--- /dev/null
+++ b/pageindicatorview/src/main/java/com/rd/animation/data/type/WormAnimationValue.java
@@ -0,0 +1,25 @@
+package com.rd.animation.data.type;
+
+import com.rd.animation.data.Value;
+
+public class WormAnimationValue implements Value {
+
+ private int rectStart;
+ private int rectEnd;
+
+ public int getRectStart() {
+ return rectStart;
+ }
+
+ public void setRectStart(int rectStartEdge) {
+ this.rectStart = rectStartEdge;
+ }
+
+ public int getRectEnd() {
+ return rectEnd;
+ }
+
+ public void setRectEnd(int rectEnd) {
+ this.rectEnd = rectEnd;
+ }
+}
diff --git a/pageindicatorview/src/main/java/com/rd/animation/type/AnimationType.java b/pageindicatorview/src/main/java/com/rd/animation/type/AnimationType.java
new file mode 100644
index 0000000..f7f0cd4
--- /dev/null
+++ b/pageindicatorview/src/main/java/com/rd/animation/type/AnimationType.java
@@ -0,0 +1,3 @@
+package com.rd.animation.type;
+
+public enum AnimationType {NONE, COLOR, SCALE, WORM, SLIDE, FILL, THIN_WORM, DROP, SWAP, SCALE_DOWN}
diff --git a/pageindicatorview/src/main/java/com/rd/animation/type/BaseAnimation.java b/pageindicatorview/src/main/java/com/rd/animation/type/BaseAnimation.java
new file mode 100644
index 0000000..22e8a25
--- /dev/null
+++ b/pageindicatorview/src/main/java/com/rd/animation/type/BaseAnimation.java
@@ -0,0 +1,48 @@
+package com.rd.animation.type;
+
+import android.animation.Animator;
+import android.animation.ValueAnimator;
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import com.rd.animation.controller.ValueController;
+
+public abstract class BaseAnimation {
+
+ public static final int DEFAULT_ANIMATION_TIME = 350;
+ protected long animationDuration = DEFAULT_ANIMATION_TIME;
+
+ protected ValueController.UpdateListener listener;
+ protected T animator;
+
+ public BaseAnimation(@Nullable ValueController.UpdateListener listener) {
+ this.listener = listener;
+ animator = createAnimator();
+ }
+
+ @NonNull
+ public abstract T createAnimator();
+
+ public abstract BaseAnimation progress(float progress);
+
+ public BaseAnimation duration(long duration) {
+ animationDuration = duration;
+
+ if (animator instanceof ValueAnimator) {
+ animator.setDuration(animationDuration);
+ }
+
+ return this;
+ }
+
+ public void start() {
+ if (animator != null && !animator.isRunning()) {
+ animator.start();
+ }
+ }
+
+ public void end() {
+ if (animator != null && animator.isStarted()) {
+ animator.end();
+ }
+ }
+}
diff --git a/pageindicatorview/src/main/java/com/rd/animation/type/ColorAnimation.java b/pageindicatorview/src/main/java/com/rd/animation/type/ColorAnimation.java
new file mode 100644
index 0000000..b119b58
--- /dev/null
+++ b/pageindicatorview/src/main/java/com/rd/animation/type/ColorAnimation.java
@@ -0,0 +1,121 @@
+package com.rd.animation.type;
+
+import android.animation.ArgbEvaluator;
+import android.animation.PropertyValuesHolder;
+import android.animation.ValueAnimator;
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import android.view.animation.AccelerateDecelerateInterpolator;
+import com.rd.animation.controller.ValueController;
+import com.rd.animation.data.type.ColorAnimationValue;
+
+public class ColorAnimation extends BaseAnimation {
+
+ public static final String DEFAULT_UNSELECTED_COLOR = "#33ffffff";
+ public static final String DEFAULT_SELECTED_COLOR = "#ffffff";
+
+ static final String ANIMATION_COLOR_REVERSE = "ANIMATION_COLOR_REVERSE";
+ static final String ANIMATION_COLOR = "ANIMATION_COLOR";
+
+ private ColorAnimationValue value;
+
+ int colorStart;
+ int colorEnd;
+
+ public ColorAnimation(@Nullable ValueController.UpdateListener listener) {
+ super(listener);
+ value = new ColorAnimationValue();
+ }
+
+ @NonNull
+ @Override
+ public ValueAnimator createAnimator() {
+ ValueAnimator animator = new ValueAnimator();
+ animator.setDuration(BaseAnimation.DEFAULT_ANIMATION_TIME);
+ animator.setInterpolator(new AccelerateDecelerateInterpolator());
+ animator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
+ @Override
+ public void onAnimationUpdate(ValueAnimator animation) {
+ onAnimateUpdated(animation);
+ }
+ });
+
+ return animator;
+ }
+
+ @Override
+ public ColorAnimation progress(float progress) {
+ if (animator != null) {
+ long playTime = (long) (progress * animationDuration);
+
+ if (animator.getValues() != null && animator.getValues().length > 0) {
+ animator.setCurrentPlayTime(playTime);
+ }
+ }
+
+ return this;
+ }
+
+ @NonNull
+ public ColorAnimation with(int colorStart, int colorEnd) {
+ if (animator != null && hasChanges(colorStart, colorEnd)) {
+
+ this.colorStart = colorStart;
+ this.colorEnd = colorEnd;
+
+ PropertyValuesHolder colorHolder = createColorPropertyHolder(false);
+ PropertyValuesHolder reverseColorHolder = createColorPropertyHolder(true);
+
+ animator.setValues(colorHolder, reverseColorHolder);
+ }
+
+ return this;
+ }
+
+ PropertyValuesHolder createColorPropertyHolder(boolean isReverse) {
+ String propertyName;
+ int colorStart;
+ int colorEnd;
+
+ if (isReverse) {
+ propertyName = ANIMATION_COLOR_REVERSE;
+ colorStart = this.colorEnd;
+ colorEnd = this.colorStart;
+
+ } else {
+ propertyName = ANIMATION_COLOR;
+ colorStart = this.colorStart;
+ colorEnd = this.colorEnd;
+ }
+
+ PropertyValuesHolder holder = PropertyValuesHolder.ofInt(propertyName, colorStart, colorEnd);
+ holder.setEvaluator(new ArgbEvaluator());
+
+ return holder;
+ }
+
+ @SuppressWarnings("RedundantIfStatement")
+ private boolean hasChanges(int colorStart, int colorEnd) {
+ if (this.colorStart != colorStart) {
+ return true;
+ }
+
+ if (this.colorEnd != colorEnd) {
+ return true;
+ }
+
+ return false;
+ }
+
+ private void onAnimateUpdated(@NonNull ValueAnimator animation) {
+ int color = (int) animation.getAnimatedValue(ANIMATION_COLOR);
+ int colorReverse = (int) animation.getAnimatedValue(ANIMATION_COLOR_REVERSE);
+
+ value.setColor(color);
+ value.setColorReverse(colorReverse);
+
+ if (listener != null) {
+ listener.onValueUpdated(value);
+ }
+ }
+}
diff --git a/pageindicatorview/src/main/java/com/rd/animation/type/DropAnimation.java b/pageindicatorview/src/main/java/com/rd/animation/type/DropAnimation.java
new file mode 100644
index 0000000..607cf73
--- /dev/null
+++ b/pageindicatorview/src/main/java/com/rd/animation/type/DropAnimation.java
@@ -0,0 +1,171 @@
+package com.rd.animation.type;
+
+import android.animation.Animator;
+import android.animation.AnimatorSet;
+import android.animation.ValueAnimator;
+import androidx.annotation.NonNull;
+import android.view.animation.AccelerateDecelerateInterpolator;
+import com.rd.animation.controller.ValueController;
+import com.rd.animation.data.type.DropAnimationValue;
+
+public class DropAnimation extends BaseAnimation {
+
+ private int widthStart;
+ private int widthEnd;
+ private int heightStart;
+ private int heightEnd;
+ private int radius;
+
+ private enum AnimationType {Width, Height, Radius}
+
+ private DropAnimationValue value;
+
+ public DropAnimation(@NonNull ValueController.UpdateListener listener) {
+ super(listener);
+ value = new DropAnimationValue();
+ }
+
+ @NonNull
+ @Override
+ public AnimatorSet createAnimator() {
+ AnimatorSet animator = new AnimatorSet();
+ animator.setInterpolator(new AccelerateDecelerateInterpolator());
+
+ return animator;
+ }
+
+ @Override
+ public DropAnimation progress(float progress) {
+ if (animator != null) {
+ long playTimeLeft = (long) (progress * animationDuration);
+ boolean isReverse = false;
+
+ for (Animator anim : animator.getChildAnimations()) {
+ ValueAnimator animator = (ValueAnimator) anim;
+ long animDuration = animator.getDuration();
+ long currPlayTime = playTimeLeft;
+
+ if (isReverse) {
+ currPlayTime -= animDuration;
+ }
+
+ if (currPlayTime < 0) {
+ continue;
+
+ } else if (currPlayTime >= animDuration) {
+ currPlayTime = animDuration;
+ }
+
+ if (animator.getValues() != null && animator.getValues().length > 0) {
+ animator.setCurrentPlayTime(currPlayTime);
+ }
+
+ if (!isReverse && animDuration >= animationDuration) {
+ isReverse = true;
+ }
+ }
+ }
+
+ return this;
+ }
+
+ @Override
+ public DropAnimation duration(long duration) {
+ super.duration(duration);
+ return this;
+ }
+
+ @SuppressWarnings("UnnecessaryLocalVariable")
+ public DropAnimation with(int widthStart, int widthEnd, int heightStart, int heightEnd, int radius) {
+ if (hasChanges(widthStart, widthEnd, heightStart, heightEnd, radius)) {
+ animator = createAnimator();
+
+ this.widthStart = widthStart;
+ this.widthEnd = widthEnd;
+ this.heightStart = heightStart;
+ this.heightEnd = heightEnd;
+ this.radius = radius;
+
+ int fromRadius = radius;
+ int toRadius = (int) (radius / 1.5);
+ long halfDuration = animationDuration / 2;
+
+ ValueAnimator widthAnimator = createValueAnimation(widthStart, widthEnd, animationDuration, AnimationType.Width);
+ ValueAnimator heightForwardAnimator = createValueAnimation(heightStart, heightEnd, halfDuration, AnimationType.Height);
+ ValueAnimator radiusForwardAnimator = createValueAnimation(fromRadius, toRadius, halfDuration, AnimationType.Radius);
+
+ ValueAnimator heightBackwardAnimator = createValueAnimation(heightEnd, heightStart, halfDuration, AnimationType.Height);
+ ValueAnimator radiusBackwardAnimator = createValueAnimation(toRadius, fromRadius, halfDuration, AnimationType.Radius);
+
+ animator.play(heightForwardAnimator)
+ .with(radiusForwardAnimator)
+ .with(widthAnimator)
+ .before(heightBackwardAnimator)
+ .before(radiusBackwardAnimator);
+ }
+
+ return this;
+ }
+
+ private ValueAnimator createValueAnimation(int fromValue, int toValue, long duration, final AnimationType type) {
+ ValueAnimator anim = ValueAnimator.ofInt(fromValue, toValue);
+ anim.setInterpolator(new AccelerateDecelerateInterpolator());
+ anim.setDuration(duration);
+ anim.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
+ @Override
+ public void onAnimationUpdate(ValueAnimator animation) {
+ onAnimatorUpdate(animation, type);
+ }
+ });
+
+ return anim;
+ }
+
+ private void onAnimatorUpdate(@NonNull ValueAnimator animation, @NonNull AnimationType type) {
+ int frameValue = (int) animation.getAnimatedValue();
+
+ switch (type) {
+ case Width:
+ value.setWidth(frameValue);
+ break;
+
+ case Height:
+ value.setHeight(frameValue);
+ break;
+
+ case Radius:
+ value.setRadius(frameValue);
+ break;
+ }
+
+ if (listener != null) {
+ listener.onValueUpdated(value);
+ }
+ }
+
+ @SuppressWarnings("RedundantIfStatement")
+ private boolean hasChanges(int widthStart, int widthEnd, int heightStart, int heightEnd, int radius) {
+ if (this.widthStart != widthStart) {
+ return true;
+ }
+
+ if (this.widthEnd != widthEnd) {
+ return true;
+ }
+
+ if (this.heightStart != heightStart) {
+ return true;
+ }
+
+ if (this.heightEnd != heightEnd) {
+ return true;
+ }
+
+ if (this.radius != radius) {
+ return true;
+ }
+
+ return false;
+ }
+
+}
diff --git a/pageindicatorview/src/main/java/com/rd/animation/type/FillAnimation.java b/pageindicatorview/src/main/java/com/rd/animation/type/FillAnimation.java
new file mode 100644
index 0000000..390a876
--- /dev/null
+++ b/pageindicatorview/src/main/java/com/rd/animation/type/FillAnimation.java
@@ -0,0 +1,167 @@
+package com.rd.animation.type;
+
+import android.animation.IntEvaluator;
+import android.animation.PropertyValuesHolder;
+import android.animation.ValueAnimator;
+import androidx.annotation.NonNull;
+import android.view.animation.AccelerateDecelerateInterpolator;
+import com.rd.animation.controller.ValueController;
+import com.rd.animation.data.type.FillAnimationValue;
+
+public class FillAnimation extends ColorAnimation {
+
+ private static final String ANIMATION_RADIUS_REVERSE = "ANIMATION_RADIUS_REVERSE";
+ private static final String ANIMATION_RADIUS = "ANIMATION_RADIUS";
+
+ private static final String ANIMATION_STROKE_REVERSE = "ANIMATION_STROKE_REVERSE";
+ private static final String ANIMATION_STROKE = "ANIMATION_STROKE";
+
+ public static final int DEFAULT_STROKE_DP = 1;
+ private FillAnimationValue value;
+
+ private int radius;
+ private int stroke;
+
+ public FillAnimation(@NonNull ValueController.UpdateListener listener) {
+ super(listener);
+ value = new FillAnimationValue();
+ }
+
+ @NonNull
+ @Override
+ public ValueAnimator createAnimator() {
+ ValueAnimator animator = new ValueAnimator();
+ animator.setDuration(BaseAnimation.DEFAULT_ANIMATION_TIME);
+ animator.setInterpolator(new AccelerateDecelerateInterpolator());
+ animator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
+ @Override
+ public void onAnimationUpdate(ValueAnimator animation) {
+ onAnimateUpdated(animation);
+ }
+ });
+
+ return animator;
+ }
+
+ @NonNull
+ public FillAnimation with(int colorStart, int colorEnd, int radius, int stroke) {
+ if (animator != null && hasChanges(colorStart, colorEnd, radius, stroke)) {
+
+ this.colorStart = colorStart;
+ this.colorEnd = colorEnd;
+
+ this.radius = radius;
+ this.stroke = stroke;
+
+ PropertyValuesHolder colorHolder = createColorPropertyHolder(false);
+ PropertyValuesHolder reverseColorHolder = createColorPropertyHolder(true);
+
+ PropertyValuesHolder radiusHolder = createRadiusPropertyHolder(false);
+ PropertyValuesHolder radiusReverseHolder = createRadiusPropertyHolder(true);
+
+ PropertyValuesHolder strokeHolder = createStrokePropertyHolder(false);
+ PropertyValuesHolder strokeReverseHolder = createStrokePropertyHolder(true);
+
+ animator.setValues(
+ colorHolder,
+ reverseColorHolder,
+
+ radiusHolder,
+ radiusReverseHolder,
+
+ strokeHolder,
+ strokeReverseHolder);
+ }
+
+ return this;
+ }
+
+ @NonNull
+ private PropertyValuesHolder createRadiusPropertyHolder(boolean isReverse) {
+ String propertyName;
+ int startRadiusValue;
+ int endRadiusValue;
+
+ if (isReverse) {
+ propertyName = ANIMATION_RADIUS_REVERSE;
+ startRadiusValue = radius / 2;
+ endRadiusValue = radius;
+ } else {
+ propertyName = ANIMATION_RADIUS;
+ startRadiusValue = radius;
+ endRadiusValue = radius / 2;
+ }
+
+ PropertyValuesHolder holder = PropertyValuesHolder.ofInt(propertyName, startRadiusValue, endRadiusValue);
+ holder.setEvaluator(new IntEvaluator());
+
+ return holder;
+ }
+
+ @NonNull
+ private PropertyValuesHolder createStrokePropertyHolder(boolean isReverse) {
+ String propertyName;
+ int startStrokeValue;
+ int endStrokeValue;
+
+ if (isReverse) {
+ propertyName = ANIMATION_STROKE_REVERSE;
+ startStrokeValue = radius;
+ endStrokeValue = 0;
+ } else {
+ propertyName = ANIMATION_STROKE;
+ startStrokeValue = 0;
+ endStrokeValue = radius;
+ }
+
+ PropertyValuesHolder holder = PropertyValuesHolder.ofInt(propertyName, startStrokeValue, endStrokeValue);
+ holder.setEvaluator(new IntEvaluator());
+
+ return holder;
+ }
+
+ private void onAnimateUpdated(@NonNull ValueAnimator animation) {
+ int color = (int) animation.getAnimatedValue(ANIMATION_COLOR);
+ int colorReverse = (int) animation.getAnimatedValue(ANIMATION_COLOR_REVERSE);
+
+ int radius = (int) animation.getAnimatedValue(ANIMATION_RADIUS);
+ int radiusReverse = (int) animation.getAnimatedValue(ANIMATION_RADIUS_REVERSE);
+
+ int stroke = (int) animation.getAnimatedValue(ANIMATION_STROKE);
+ int strokeReverse = (int) animation.getAnimatedValue(ANIMATION_STROKE_REVERSE);
+
+ value.setColor(color);
+ value.setColorReverse(colorReverse);
+
+ value.setRadius(radius);
+ value.setRadiusReverse(radiusReverse);
+
+ value.setStroke(stroke);
+ value.setStrokeReverse(strokeReverse);
+
+ if (listener != null) {
+ listener.onValueUpdated(value);
+ }
+ }
+
+ @SuppressWarnings("RedundantIfStatement")
+ private boolean hasChanges(int colorStart, int colorEnd, int radiusValue, int strokeValue) {
+ if (this.colorStart != colorStart) {
+ return true;
+ }
+
+ if (this.colorEnd != colorEnd) {
+ return true;
+ }
+
+ if (radius != radiusValue) {
+ return true;
+ }
+
+ if (stroke != strokeValue) {
+ return true;
+ }
+
+ return false;
+ }
+}
diff --git a/pageindicatorview/src/main/java/com/rd/animation/type/ScaleAnimation.java b/pageindicatorview/src/main/java/com/rd/animation/type/ScaleAnimation.java
new file mode 100644
index 0000000..1f1c6c4
--- /dev/null
+++ b/pageindicatorview/src/main/java/com/rd/animation/type/ScaleAnimation.java
@@ -0,0 +1,129 @@
+package com.rd.animation.type;
+
+import android.animation.IntEvaluator;
+import android.animation.PropertyValuesHolder;
+import android.animation.ValueAnimator;
+import androidx.annotation.NonNull;
+import android.view.animation.AccelerateDecelerateInterpolator;
+import com.rd.animation.controller.ValueController;
+import com.rd.animation.data.type.ScaleAnimationValue;
+
+public class ScaleAnimation extends ColorAnimation {
+
+ public static final float DEFAULT_SCALE_FACTOR = 0.7f;
+ public static final float MIN_SCALE_FACTOR = 0.3f;
+ public static final float MAX_SCALE_FACTOR = 1;
+
+ static final String ANIMATION_SCALE_REVERSE = "ANIMATION_SCALE_REVERSE";
+ static final String ANIMATION_SCALE = "ANIMATION_SCALE";
+
+ int radius;
+ float scaleFactor;
+
+ private ScaleAnimationValue value;
+
+ public ScaleAnimation(@NonNull ValueController.UpdateListener listener) {
+ super(listener);
+ value = new ScaleAnimationValue();
+ }
+
+ @NonNull
+ @Override
+ public ValueAnimator createAnimator() {
+ ValueAnimator animator = new ValueAnimator();
+ animator.setDuration(BaseAnimation.DEFAULT_ANIMATION_TIME);
+ animator.setInterpolator(new AccelerateDecelerateInterpolator());
+ animator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
+ @Override
+ public void onAnimationUpdate(ValueAnimator animation) {
+ onAnimateUpdated(animation);
+ }
+ });
+
+ return animator;
+ }
+
+ @NonNull
+ public ScaleAnimation with(int colorStart, int colorEnd, int radius, float scaleFactor) {
+ if (animator != null && hasChanges(colorStart, colorEnd, radius, scaleFactor)) {
+
+ this.colorStart = colorStart;
+ this.colorEnd = colorEnd;
+
+ this.radius = radius;
+ this.scaleFactor = scaleFactor;
+
+ PropertyValuesHolder colorHolder = createColorPropertyHolder(false);
+ PropertyValuesHolder reverseColorHolder = createColorPropertyHolder(true);
+
+ PropertyValuesHolder scaleHolder = createScalePropertyHolder(false);
+ PropertyValuesHolder scaleReverseHolder = createScalePropertyHolder(true);
+
+ animator.setValues(colorHolder, reverseColorHolder, scaleHolder, scaleReverseHolder);
+ }
+
+ return this;
+ }
+
+ private void onAnimateUpdated(@NonNull ValueAnimator animation) {
+ int color = (int) animation.getAnimatedValue(ANIMATION_COLOR);
+ int colorReverse = (int) animation.getAnimatedValue(ANIMATION_COLOR_REVERSE);
+
+ int radius = (int) animation.getAnimatedValue(ANIMATION_SCALE);
+ int radiusReverse = (int) animation.getAnimatedValue(ANIMATION_SCALE_REVERSE);
+
+ value.setColor(color);
+ value.setColorReverse(colorReverse);
+
+ value.setRadius(radius);
+ value.setRadiusReverse(radiusReverse);
+
+ if (listener != null) {
+ listener.onValueUpdated(value);
+ }
+ }
+
+ @NonNull
+ protected PropertyValuesHolder createScalePropertyHolder(boolean isReverse) {
+ String propertyName;
+ int startRadiusValue;
+ int endRadiusValue;
+
+ if (isReverse) {
+ propertyName = ANIMATION_SCALE_REVERSE;
+ startRadiusValue = radius;
+ endRadiusValue = (int) (radius * scaleFactor);
+ } else {
+ propertyName = ANIMATION_SCALE;
+ startRadiusValue = (int) (radius * scaleFactor);
+ endRadiusValue = radius;
+ }
+
+ PropertyValuesHolder holder = PropertyValuesHolder.ofInt(propertyName, startRadiusValue, endRadiusValue);
+ holder.setEvaluator(new IntEvaluator());
+
+ return holder;
+ }
+
+ @SuppressWarnings("RedundantIfStatement")
+ private boolean hasChanges(int colorStart, int colorEnd, int radiusValue, float scaleFactorValue) {
+ if (this.colorStart != colorStart) {
+ return true;
+ }
+
+ if (this.colorEnd != colorEnd) {
+ return true;
+ }
+
+ if (radius != radiusValue) {
+ return true;
+ }
+
+ if (scaleFactor != scaleFactorValue) {
+ return true;
+ }
+
+ return false;
+ }
+}
+
diff --git a/pageindicatorview/src/main/java/com/rd/animation/type/ScaleDownAnimation.java b/pageindicatorview/src/main/java/com/rd/animation/type/ScaleDownAnimation.java
new file mode 100644
index 0000000..4e9fa1c
--- /dev/null
+++ b/pageindicatorview/src/main/java/com/rd/animation/type/ScaleDownAnimation.java
@@ -0,0 +1,37 @@
+package com.rd.animation.type;
+
+import android.animation.IntEvaluator;
+import android.animation.PropertyValuesHolder;
+import androidx.annotation.NonNull;
+import com.rd.animation.controller.ValueController;
+
+public class ScaleDownAnimation extends ScaleAnimation {
+
+ public ScaleDownAnimation(@NonNull ValueController.UpdateListener listener) {
+ super(listener);
+ }
+
+ @NonNull
+ @Override
+ protected PropertyValuesHolder createScalePropertyHolder(boolean isReverse) {
+ String propertyName;
+ int startRadiusValue;
+ int endRadiusValue;
+
+ if (isReverse) {
+ propertyName = ANIMATION_SCALE_REVERSE;
+ startRadiusValue = (int) (radius * scaleFactor);
+ endRadiusValue = radius;
+ } else {
+ propertyName = ANIMATION_SCALE;
+ startRadiusValue = radius;
+ endRadiusValue = (int) (radius * scaleFactor);
+ }
+
+ PropertyValuesHolder holder = PropertyValuesHolder.ofInt(propertyName, startRadiusValue, endRadiusValue);
+ holder.setEvaluator(new IntEvaluator());
+
+ return holder;
+ }
+}
+
diff --git a/pageindicatorview/src/main/java/com/rd/animation/type/SlideAnimation.java b/pageindicatorview/src/main/java/com/rd/animation/type/SlideAnimation.java
new file mode 100644
index 0000000..ef7a581
--- /dev/null
+++ b/pageindicatorview/src/main/java/com/rd/animation/type/SlideAnimation.java
@@ -0,0 +1,96 @@
+package com.rd.animation.type;
+
+import android.animation.IntEvaluator;
+import android.animation.PropertyValuesHolder;
+import android.animation.ValueAnimator;
+import androidx.annotation.NonNull;
+import android.view.animation.AccelerateDecelerateInterpolator;
+import com.rd.animation.controller.ValueController;
+import com.rd.animation.data.type.SlideAnimationValue;
+
+public class SlideAnimation extends BaseAnimation {
+
+ private static final String ANIMATION_COORDINATE = "ANIMATION_COORDINATE";
+ private static final int COORDINATE_NONE = -1;
+
+ private SlideAnimationValue value;
+ private int coordinateStart = COORDINATE_NONE;
+ private int coordinateEnd = COORDINATE_NONE;
+
+ public SlideAnimation(@NonNull ValueController.UpdateListener listener) {
+ super(listener);
+ value = new SlideAnimationValue();
+ }
+
+ @NonNull
+ @Override
+ public ValueAnimator createAnimator() {
+ ValueAnimator animator = new ValueAnimator();
+ animator.setDuration(BaseAnimation.DEFAULT_ANIMATION_TIME);
+ animator.setInterpolator(new AccelerateDecelerateInterpolator());
+ animator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
+ @Override
+ public void onAnimationUpdate(ValueAnimator animation) {
+ onAnimateUpdated(animation);
+ }
+ });
+
+ return animator;
+ }
+
+ @Override
+ public SlideAnimation progress(float progress) {
+ if (animator != null) {
+ long playTime = (long) (progress * animationDuration);
+
+ if (animator.getValues() != null && animator.getValues().length > 0) {
+ animator.setCurrentPlayTime(playTime);
+ }
+ }
+
+ return this;
+ }
+
+ @NonNull
+ public SlideAnimation with(int coordinateStart, int coordinateEnd) {
+ if (animator != null && hasChanges(coordinateStart, coordinateEnd)) {
+
+ this.coordinateStart = coordinateStart;
+ this.coordinateEnd = coordinateEnd;
+
+ PropertyValuesHolder holder = createSlidePropertyHolder();
+ animator.setValues(holder);
+ }
+
+ return this;
+ }
+
+ private PropertyValuesHolder createSlidePropertyHolder() {
+ PropertyValuesHolder holder = PropertyValuesHolder.ofInt(ANIMATION_COORDINATE, coordinateStart, coordinateEnd);
+ holder.setEvaluator(new IntEvaluator());
+
+ return holder;
+ }
+
+ private void onAnimateUpdated(@NonNull ValueAnimator animation) {
+ int coordinate = (int) animation.getAnimatedValue(ANIMATION_COORDINATE);
+ value.setCoordinate(coordinate);
+
+ if (listener != null) {
+ listener.onValueUpdated(value);
+ }
+ }
+
+ @SuppressWarnings("RedundantIfStatement")
+ private boolean hasChanges(int coordinateStart, int coordinateEnd) {
+ if (this.coordinateStart != coordinateStart) {
+ return true;
+ }
+
+ if (this.coordinateEnd != coordinateEnd) {
+ return true;
+ }
+
+ return false;
+ }
+}
diff --git a/pageindicatorview/src/main/java/com/rd/animation/type/SwapAnimation.java b/pageindicatorview/src/main/java/com/rd/animation/type/SwapAnimation.java
new file mode 100644
index 0000000..b738c11
--- /dev/null
+++ b/pageindicatorview/src/main/java/com/rd/animation/type/SwapAnimation.java
@@ -0,0 +1,101 @@
+package com.rd.animation.type;
+
+import android.animation.IntEvaluator;
+import android.animation.PropertyValuesHolder;
+import android.animation.ValueAnimator;
+import androidx.annotation.NonNull;
+import android.view.animation.AccelerateDecelerateInterpolator;
+import com.rd.animation.controller.ValueController;
+import com.rd.animation.data.type.SwapAnimationValue;
+
+public class SwapAnimation extends BaseAnimation {
+
+ private static final String ANIMATION_COORDINATE = "ANIMATION_COORDINATE";
+ private static final String ANIMATION_COORDINATE_REVERSE = "ANIMATION_COORDINATE_REVERSE";
+ private static final int COORDINATE_NONE = -1;
+
+ private int coordinateStart = COORDINATE_NONE;
+ private int coordinateEnd = COORDINATE_NONE;
+
+ private SwapAnimationValue value;
+
+ public SwapAnimation(@NonNull ValueController.UpdateListener listener) {
+ super(listener);
+ value = new SwapAnimationValue();
+ }
+
+ @NonNull
+ @Override
+ public ValueAnimator createAnimator() {
+ ValueAnimator animator = new ValueAnimator();
+ animator.setDuration(BaseAnimation.DEFAULT_ANIMATION_TIME);
+ animator.setInterpolator(new AccelerateDecelerateInterpolator());
+ animator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
+ @Override
+ public void onAnimationUpdate(ValueAnimator animation) {
+ onAnimateUpdated(animation);
+ }
+ });
+
+ return animator;
+ }
+
+ @Override
+ public SwapAnimation progress(float progress) {
+ if (animator != null) {
+ long playTime = (long) (progress * animationDuration);
+
+ if (animator.getValues() != null && animator.getValues().length > 0) {
+ animator.setCurrentPlayTime(playTime);
+ }
+ }
+
+ return this;
+ }
+
+ @NonNull
+ public SwapAnimation with(int coordinateStart, int coordinateEnd) {
+ if (animator != null && hasChanges(coordinateStart, coordinateEnd)) {
+ this.coordinateStart = coordinateStart;
+ this.coordinateEnd = coordinateEnd;
+
+ PropertyValuesHolder holder = createColorPropertyHolder(ANIMATION_COORDINATE, coordinateStart, coordinateEnd);
+ PropertyValuesHolder holderReverse = createColorPropertyHolder(ANIMATION_COORDINATE_REVERSE, coordinateEnd, coordinateStart);
+ animator.setValues(holder, holderReverse);
+ }
+
+ return this;
+ }
+
+ private PropertyValuesHolder createColorPropertyHolder(String propertyName, int startValue, int endValue) {
+ PropertyValuesHolder holder = PropertyValuesHolder.ofInt(propertyName, startValue, endValue);
+ holder.setEvaluator(new IntEvaluator());
+
+ return holder;
+ }
+
+ private void onAnimateUpdated(@NonNull ValueAnimator animation) {
+ int coordinate = (int) animation.getAnimatedValue(ANIMATION_COORDINATE);
+ int coordinateReverse = (int) animation.getAnimatedValue(ANIMATION_COORDINATE_REVERSE);
+
+ value.setCoordinate(coordinate);
+ value.setCoordinateReverse(coordinateReverse);
+
+ if (listener != null) {
+ listener.onValueUpdated(value);
+ }
+ }
+
+ @SuppressWarnings("RedundantIfStatement")
+ private boolean hasChanges(int coordinateStart, int coordinateEnd) {
+ if (this.coordinateStart != coordinateStart) {
+ return true;
+ }
+
+ if (this.coordinateEnd != coordinateEnd) {
+ return true;
+ }
+
+ return false;
+ }
+}
diff --git a/pageindicatorview/src/main/java/com/rd/animation/type/ThinWormAnimation.java b/pageindicatorview/src/main/java/com/rd/animation/type/ThinWormAnimation.java
new file mode 100644
index 0000000..ced0f37
--- /dev/null
+++ b/pageindicatorview/src/main/java/com/rd/animation/type/ThinWormAnimation.java
@@ -0,0 +1,116 @@
+package com.rd.animation.type;
+
+import android.animation.ValueAnimator;
+import androidx.annotation.NonNull;
+import android.view.animation.AccelerateDecelerateInterpolator;
+import com.rd.animation.controller.ValueController;
+import com.rd.animation.data.type.ThinWormAnimationValue;
+
+public class ThinWormAnimation extends WormAnimation {
+
+ private ThinWormAnimationValue value;
+
+ public ThinWormAnimation(@NonNull ValueController.UpdateListener listener) {
+ super(listener);
+ value = new ThinWormAnimationValue();
+ }
+
+ @Override
+ public ThinWormAnimation duration(long duration) {
+ super.duration(duration);
+ return this;
+ }
+
+ @Override
+ public WormAnimation with(int coordinateStart, int coordinateEnd, int radius, boolean isRightSide) {
+ if (hasChanges(coordinateStart, coordinateEnd, radius, isRightSide)) {
+ animator = createAnimator();
+
+ this.coordinateStart = coordinateStart;
+ this.coordinateEnd = coordinateEnd;
+
+ this.radius = radius;
+ this.isRightSide = isRightSide;
+
+ int height = radius * 2;
+ rectLeftEdge = coordinateStart - radius;
+ rectRightEdge = coordinateStart + radius;
+
+ value.setRectStart(rectLeftEdge);
+ value.setRectEnd(rectRightEdge);
+ value.setHeight(height);
+
+ RectValues rec = createRectValues(isRightSide);
+ long sizeDuration = (long) (animationDuration * 0.8);
+ long reverseDelay = (long) (animationDuration * 0.2);
+
+ long heightDuration = (long) (animationDuration * 0.5);
+ long reverseHeightDelay = (long) (animationDuration * 0.5);
+
+ ValueAnimator straightAnimator = createWormAnimator(rec.fromX, rec.toX, sizeDuration, false, value);
+ ValueAnimator reverseAnimator = createWormAnimator(rec.reverseFromX, rec.reverseToX, sizeDuration, true, value);
+ reverseAnimator.setStartDelay(reverseDelay);
+
+ ValueAnimator straightHeightAnimator = createHeightAnimator(height, radius, heightDuration);
+ ValueAnimator reverseHeightAnimator = createHeightAnimator(radius, height, heightDuration);
+ reverseHeightAnimator.setStartDelay(reverseHeightDelay);
+
+ animator.playTogether(straightAnimator, reverseAnimator, straightHeightAnimator, reverseHeightAnimator);
+ }
+ return this;
+ }
+
+ private ValueAnimator createHeightAnimator(int fromHeight, int toHeight, long duration) {
+ ValueAnimator anim = ValueAnimator.ofInt(fromHeight, toHeight);
+ anim.setInterpolator(new AccelerateDecelerateInterpolator());
+ anim.setDuration(duration);
+ anim.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
+ @Override
+ public void onAnimationUpdate(ValueAnimator animation) {
+ onAnimateUpdated(animation);
+ }
+ });
+
+ return anim;
+ }
+
+ private void onAnimateUpdated(@NonNull ValueAnimator animation) {
+ value.setHeight((int) animation.getAnimatedValue());
+
+ if (listener != null) {
+ listener.onValueUpdated(value);
+ }
+ }
+
+ @Override
+ public ThinWormAnimation progress(float progress) {
+ if (animator != null) {
+ long progressDuration = (long) (progress * animationDuration);
+ int size = animator.getChildAnimations().size();
+
+ for (int i = 0; i < size; i++) {
+ ValueAnimator anim = (ValueAnimator) animator.getChildAnimations().get(i);
+
+ long setDuration = progressDuration - anim.getStartDelay();
+ long duration = anim.getDuration();
+
+ if (setDuration > duration) {
+ setDuration = duration;
+
+ } else if (setDuration < 0) {
+ setDuration = 0;
+ }
+
+ if (i == size - 1 && setDuration <= 0) {
+ continue;
+ }
+
+ if (anim.getValues() != null && anim.getValues().length > 0) {
+ anim.setCurrentPlayTime(setDuration);
+ }
+ }
+ }
+
+ return this;
+ }
+}
\ No newline at end of file
diff --git a/pageindicatorview/src/main/java/com/rd/animation/type/WormAnimation.java b/pageindicatorview/src/main/java/com/rd/animation/type/WormAnimation.java
new file mode 100644
index 0000000..9f87d6b
--- /dev/null
+++ b/pageindicatorview/src/main/java/com/rd/animation/type/WormAnimation.java
@@ -0,0 +1,199 @@
+package com.rd.animation.type;
+
+import android.animation.Animator;
+import android.animation.AnimatorSet;
+import android.animation.ValueAnimator;
+import androidx.annotation.NonNull;
+import android.view.animation.AccelerateDecelerateInterpolator;
+import com.rd.animation.controller.ValueController;
+import com.rd.animation.data.type.WormAnimationValue;
+
+public class WormAnimation extends BaseAnimation {
+
+ int coordinateStart;
+ int coordinateEnd;
+
+ int radius;
+ boolean isRightSide;
+
+ int rectLeftEdge;
+ int rectRightEdge;
+
+ private WormAnimationValue value;
+
+ public WormAnimation(@NonNull ValueController.UpdateListener listener) {
+ super(listener);
+ value = new WormAnimationValue();
+ }
+
+ @NonNull
+ @Override
+ public AnimatorSet createAnimator() {
+ AnimatorSet animator = new AnimatorSet();
+ animator.setInterpolator(new AccelerateDecelerateInterpolator());
+
+ return animator;
+ }
+
+ @Override
+ public WormAnimation duration(long duration) {
+ super.duration(duration);
+ return this;
+ }
+
+ public WormAnimation with(int coordinateStart, int coordinateEnd, int radius, boolean isRightSide) {
+ if (hasChanges(coordinateStart, coordinateEnd, radius, isRightSide)) {
+ animator = createAnimator();
+
+ this.coordinateStart = coordinateStart;
+ this.coordinateEnd = coordinateEnd;
+
+ this.radius = radius;
+ this.isRightSide = isRightSide;
+
+ rectLeftEdge = coordinateStart - radius;
+ rectRightEdge = coordinateStart + radius;
+
+ value.setRectStart(rectLeftEdge);
+ value.setRectEnd(rectRightEdge);
+
+ RectValues rect = createRectValues(isRightSide);
+ long duration = animationDuration / 2;
+
+ ValueAnimator straightAnimator = createWormAnimator(rect.fromX, rect.toX, duration, false, value);
+ ValueAnimator reverseAnimator = createWormAnimator(rect.reverseFromX, rect.reverseToX, duration, true, value);
+ animator.playSequentially(straightAnimator, reverseAnimator);
+ }
+ return this;
+ }
+
+ @Override
+ public WormAnimation progress(float progress) {
+ if (animator == null) {
+ return this;
+ }
+
+ long progressDuration = (long) (progress * animationDuration);
+ for (Animator anim : animator.getChildAnimations()) {
+ ValueAnimator animator = (ValueAnimator) anim;
+ long duration = animator.getDuration();
+ long setDuration = progressDuration;
+
+ if (setDuration > duration) {
+ setDuration = duration;
+ }
+
+ animator.setCurrentPlayTime(setDuration);
+ progressDuration -= setDuration;
+ }
+
+ return this;
+ }
+
+ ValueAnimator createWormAnimator(
+ int fromValue,
+ int toValue,
+ long duration,
+ final boolean isReverse,
+ final WormAnimationValue value) {
+
+ ValueAnimator anim = ValueAnimator.ofInt(fromValue, toValue);
+ anim.setInterpolator(new AccelerateDecelerateInterpolator());
+ anim.setDuration(duration);
+ anim.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
+ @Override
+ public void onAnimationUpdate(ValueAnimator animation) {
+ onAnimateUpdated(value, animation, isReverse);
+ }
+ });
+
+ return anim;
+ }
+
+ private void onAnimateUpdated(@NonNull WormAnimationValue value, @NonNull ValueAnimator animation, final boolean isReverse) {
+ int rectEdge = (int) animation.getAnimatedValue();
+
+ if (isRightSide) {
+ if (!isReverse) {
+ value.setRectEnd(rectEdge);
+ } else {
+ value.setRectStart(rectEdge);
+ }
+
+ } else {
+ if (!isReverse) {
+ value.setRectStart(rectEdge);
+ } else {
+ value.setRectEnd(rectEdge);
+ }
+ }
+
+ if (listener != null) {
+ listener.onValueUpdated(value);
+ }
+ }
+
+ @SuppressWarnings("RedundantIfStatement")
+ boolean hasChanges(int coordinateStart, int coordinateEnd, int radius, boolean isRightSide) {
+ if (this.coordinateStart != coordinateStart) {
+ return true;
+ }
+
+ if (this.coordinateEnd != coordinateEnd) {
+ return true;
+ }
+
+ if (this.radius != radius) {
+ return true;
+ }
+
+ if (this.isRightSide != isRightSide) {
+ return true;
+ }
+
+ return false;
+ }
+
+ @NonNull
+ RectValues createRectValues(boolean isRightSide) {
+ int fromX;
+ int toX;
+
+ int reverseFromX;
+ int reverseToX;
+
+ if (isRightSide) {
+ fromX = coordinateStart + radius;
+ toX = coordinateEnd + radius;
+
+ reverseFromX = coordinateStart - radius;
+ reverseToX = coordinateEnd - radius;
+
+ } else {
+ fromX = coordinateStart - radius;
+ toX = coordinateEnd - radius;
+
+ reverseFromX = coordinateStart + radius;
+ reverseToX = coordinateEnd + radius;
+ }
+
+ return new RectValues(fromX, toX, reverseFromX, reverseToX);
+ }
+
+ class RectValues {
+
+ final int fromX;
+ final int toX;
+
+ final int reverseFromX;
+ final int reverseToX;
+
+ RectValues(int fromX, int toX, int reverseFromX, int reverseToX) {
+ this.fromX = fromX;
+ this.toX = toX;
+
+ this.reverseFromX = reverseFromX;
+ this.reverseToX = reverseToX;
+ }
+ }
+}
diff --git a/pageindicatorview/src/main/java/com/rd/draw/DrawManager.java b/pageindicatorview/src/main/java/com/rd/draw/DrawManager.java
new file mode 100644
index 0000000..08a11a3
--- /dev/null
+++ b/pageindicatorview/src/main/java/com/rd/draw/DrawManager.java
@@ -0,0 +1,62 @@
+package com.rd.draw;
+
+import android.content.Context;
+import android.graphics.Canvas;
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import android.util.AttributeSet;
+import android.util.Pair;
+import android.view.MotionEvent;
+import com.rd.animation.data.Value;
+import com.rd.draw.controller.AttributeController;
+import com.rd.draw.controller.DrawController;
+import com.rd.draw.controller.MeasureController;
+import com.rd.draw.data.Indicator;
+
+public class DrawManager {
+
+ private Indicator indicator;
+ private DrawController drawController;
+ private MeasureController measureController;
+ private AttributeController attributeController;
+
+ public DrawManager() {
+ this.indicator = new Indicator();
+ this.drawController = new DrawController(indicator);
+ this.measureController = new MeasureController();
+ this.attributeController = new AttributeController(indicator);
+ }
+
+ @NonNull
+ public Indicator indicator() {
+ if (indicator == null) {
+ indicator = new Indicator();
+ }
+
+ return indicator;
+ }
+
+ public void setClickListener(@Nullable DrawController.ClickListener listener) {
+ drawController.setClickListener(listener);
+ }
+
+ public void touch(@Nullable MotionEvent event) {
+ drawController.touch(event);
+ }
+
+ public void updateValue(@Nullable Value value) {
+ drawController.updateValue(value);
+ }
+
+ public void draw(@NonNull Canvas canvas) {
+ drawController.draw(canvas);
+ }
+
+ public Pair measureViewSize(int widthMeasureSpec, int heightMeasureSpec) {
+ return measureController.measureViewSize(indicator, widthMeasureSpec, heightMeasureSpec);
+ }
+
+ public void initAttributes(@NonNull Context context, @Nullable AttributeSet attrs) {
+ attributeController.init(context, attrs);
+ }
+}
diff --git a/pageindicatorview/src/main/java/com/rd/draw/controller/AttributeController.java b/pageindicatorview/src/main/java/com/rd/draw/controller/AttributeController.java
new file mode 100644
index 0000000..146b4b0
--- /dev/null
+++ b/pageindicatorview/src/main/java/com/rd/draw/controller/AttributeController.java
@@ -0,0 +1,183 @@
+package com.rd.draw.controller;
+
+import android.content.Context;
+import android.content.res.TypedArray;
+import android.graphics.Color;
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import android.util.AttributeSet;
+import android.view.View;
+
+import com.rd.animation.type.AnimationType;
+import com.rd.animation.type.BaseAnimation;
+import com.rd.animation.type.ColorAnimation;
+import com.rd.animation.type.FillAnimation;
+import com.rd.animation.type.ScaleAnimation;
+import com.rd.draw.data.Indicator;
+import com.rd.draw.data.Orientation;
+import com.rd.draw.data.RtlMode;
+import com.rd.pageindicatorview.R;
+import com.rd.utils.DensityUtils;
+
+public class AttributeController {
+
+ private Indicator indicator;
+
+ private static final int DEFAULT_IDLE_DURATION = 3000;
+
+ public AttributeController(@NonNull Indicator indicator) {
+ this.indicator = indicator;
+ }
+
+ public void init(@NonNull Context context, @Nullable AttributeSet attrs) {
+ TypedArray typedArray = context.obtainStyledAttributes(attrs, R.styleable.PageIndicatorView, 0, 0);
+ initCountAttribute(typedArray);
+ initColorAttribute(typedArray);
+ initAnimationAttribute(typedArray);
+ initSizeAttribute(typedArray);
+ typedArray.recycle();
+ }
+
+ private void initCountAttribute(@NonNull TypedArray typedArray) {
+ int viewPagerId = typedArray.getResourceId(R.styleable.PageIndicatorView_piv_viewPager, View.NO_ID);
+ boolean autoVisibility = typedArray.getBoolean(R.styleable.PageIndicatorView_piv_autoVisibility, true);
+ boolean dynamicCount = typedArray.getBoolean(R.styleable.PageIndicatorView_piv_dynamicCount, false);
+ int count = typedArray.getInt(R.styleable.PageIndicatorView_piv_count, Indicator.COUNT_NONE);
+
+ if (count == Indicator.COUNT_NONE) {
+ count = Indicator.DEFAULT_COUNT;
+ }
+
+ int position = typedArray.getInt(R.styleable.PageIndicatorView_piv_select, 0);
+ if (position < 0) {
+ position = 0;
+ } else if (count > 0 && position > count - 1) {
+ position = count - 1;
+ }
+
+ indicator.setViewPagerId(viewPagerId);
+ indicator.setAutoVisibility(autoVisibility);
+ indicator.setDynamicCount(dynamicCount);
+ indicator.setCount(count);
+
+ indicator.setSelectedPosition(position);
+ indicator.setSelectingPosition(position);
+ indicator.setLastSelectedPosition(position);
+ }
+
+ private void initColorAttribute(@NonNull TypedArray typedArray) {
+ int unselectedColor = typedArray.getColor(R.styleable.PageIndicatorView_piv_unselectedColor, Color.parseColor(ColorAnimation.DEFAULT_UNSELECTED_COLOR));
+ int selectedColor = typedArray.getColor(R.styleable.PageIndicatorView_piv_selectedColor, Color.parseColor(ColorAnimation.DEFAULT_SELECTED_COLOR));
+
+ indicator.setUnselectedColor(unselectedColor);
+ indicator.setSelectedColor(selectedColor);
+ }
+
+ private void initAnimationAttribute(@NonNull TypedArray typedArray) {
+ boolean interactiveAnimation = typedArray.getBoolean(R.styleable.PageIndicatorView_piv_interactiveAnimation, false);
+ long animationDuration = (long) typedArray.getInt(R.styleable.PageIndicatorView_piv_animationDuration, BaseAnimation.DEFAULT_ANIMATION_TIME);
+ if (animationDuration < 0) {
+ animationDuration = 0;
+ }
+
+ int animIndex = typedArray.getInt(R.styleable.PageIndicatorView_piv_animationType, AnimationType.NONE.ordinal());
+ AnimationType animationType = getAnimationType(animIndex);
+
+ int rtlIndex = typedArray.getInt(R.styleable.PageIndicatorView_piv_rtl_mode, RtlMode.Off.ordinal());
+ RtlMode rtlMode = getRtlMode(rtlIndex);
+
+ boolean fadeOnIdle = typedArray.getBoolean(R.styleable.PageIndicatorView_piv_fadeOnIdle, false);
+ long idleDuration = (long) typedArray.getInt(R.styleable.PageIndicatorView_piv_idleDuration, DEFAULT_IDLE_DURATION);
+
+ indicator.setAnimationDuration(animationDuration);
+ indicator.setInteractiveAnimation(interactiveAnimation);
+ indicator.setAnimationType(animationType);
+ indicator.setRtlMode(rtlMode);
+ indicator.setFadeOnIdle(fadeOnIdle);
+ indicator.setIdleDuration(idleDuration);
+ }
+
+ private void initSizeAttribute(@NonNull TypedArray typedArray) {
+ int orientationIndex = typedArray.getInt(R.styleable.PageIndicatorView_piv_orientation, Orientation.HORIZONTAL.ordinal());
+ Orientation orientation;
+
+ if (orientationIndex == 0) {
+ orientation = Orientation.HORIZONTAL;
+ } else {
+ orientation = Orientation.VERTICAL;
+ }
+
+ int radius = (int) typedArray.getDimension(R.styleable.PageIndicatorView_piv_radius, DensityUtils.dpToPx(Indicator.DEFAULT_RADIUS_DP));
+ if (radius < 0) {
+ radius = 0;
+ }
+
+ int padding = (int) typedArray.getDimension(R.styleable.PageIndicatorView_piv_padding, DensityUtils.dpToPx(Indicator.DEFAULT_PADDING_DP));
+ if (padding < 0) {
+ padding = 0;
+ }
+
+ float scaleFactor = typedArray.getFloat(R.styleable.PageIndicatorView_piv_scaleFactor, ScaleAnimation.DEFAULT_SCALE_FACTOR);
+ if (scaleFactor < ScaleAnimation.MIN_SCALE_FACTOR) {
+ scaleFactor = ScaleAnimation.MIN_SCALE_FACTOR;
+
+ } else if (scaleFactor > ScaleAnimation.MAX_SCALE_FACTOR) {
+ scaleFactor = ScaleAnimation.MAX_SCALE_FACTOR;
+ }
+
+ int stroke = (int) typedArray.getDimension(R.styleable.PageIndicatorView_piv_strokeWidth, DensityUtils.dpToPx(FillAnimation.DEFAULT_STROKE_DP));
+ if (stroke > radius) {
+ stroke = radius;
+ }
+
+ if (indicator.getAnimationType() != AnimationType.FILL) {
+ stroke = 0;
+ }
+
+ indicator.setRadius(radius);
+ indicator.setOrientation(orientation);
+ indicator.setPadding(padding);
+ indicator.setScaleFactor(scaleFactor);
+ indicator.setStroke(stroke);
+ }
+
+ private AnimationType getAnimationType(int index) {
+ switch (index) {
+ case 0:
+ return AnimationType.NONE;
+ case 1:
+ return AnimationType.COLOR;
+ case 2:
+ return AnimationType.SCALE;
+ case 3:
+ return AnimationType.WORM;
+ case 4:
+ return AnimationType.SLIDE;
+ case 5:
+ return AnimationType.FILL;
+ case 6:
+ return AnimationType.THIN_WORM;
+ case 7:
+ return AnimationType.DROP;
+ case 8:
+ return AnimationType.SWAP;
+ case 9:
+ return AnimationType.SCALE_DOWN;
+ }
+
+ return AnimationType.NONE;
+ }
+
+ private RtlMode getRtlMode(int index) {
+ switch (index) {
+ case 0:
+ return RtlMode.On;
+ case 1:
+ return RtlMode.Off;
+ case 2:
+ return RtlMode.Auto;
+ }
+
+ return RtlMode.Auto;
+ }
+}
diff --git a/pageindicatorview/src/main/java/com/rd/draw/controller/DrawController.java b/pageindicatorview/src/main/java/com/rd/draw/controller/DrawController.java
new file mode 100644
index 0000000..d338171
--- /dev/null
+++ b/pageindicatorview/src/main/java/com/rd/draw/controller/DrawController.java
@@ -0,0 +1,137 @@
+package com.rd.draw.controller;
+
+import android.graphics.Canvas;
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import android.view.MotionEvent;
+import com.rd.animation.data.Value;
+import com.rd.animation.type.AnimationType;
+import com.rd.draw.data.Indicator;
+import com.rd.draw.drawer.Drawer;
+import com.rd.utils.CoordinatesUtils;
+
+public class DrawController {
+
+ private Value value;
+ private Drawer drawer;
+ private Indicator indicator;
+ private ClickListener listener;
+
+ public interface ClickListener {
+
+ void onIndicatorClicked(int position);
+ }
+
+ public DrawController(@NonNull Indicator indicator) {
+ this.indicator = indicator;
+ this.drawer = new Drawer(indicator);
+ }
+
+ public void updateValue(@Nullable Value value) {
+ this.value = value;
+ }
+
+ public void setClickListener(@Nullable ClickListener listener) {
+ this.listener = listener;
+ }
+
+ public void touch(@Nullable MotionEvent event) {
+ if (event == null) {
+ return;
+ }
+
+ switch (event.getAction()) {
+ case MotionEvent.ACTION_UP:
+ onIndicatorTouched(event.getX(), event.getY());
+ break;
+ default:
+ }
+ }
+
+ private void onIndicatorTouched(float x, float y) {
+ if (listener != null) {
+ int position = CoordinatesUtils.getPosition(indicator, x, y);
+ if (position >= 0) {
+ listener.onIndicatorClicked(position);
+ }
+ }
+ }
+
+ public void draw(@NonNull Canvas canvas) {
+ int count = indicator.getCount();
+
+ for (int position = 0; position < count; position++) {
+ int coordinateX = CoordinatesUtils.getXCoordinate(indicator, position);
+ int coordinateY = CoordinatesUtils.getYCoordinate(indicator, position);
+ drawIndicator(canvas, position, coordinateX, coordinateY);
+ }
+ }
+
+ private void drawIndicator(
+ @NonNull Canvas canvas,
+ int position,
+ int coordinateX,
+ int coordinateY) {
+
+ boolean interactiveAnimation = indicator.isInteractiveAnimation();
+ int selectedPosition = indicator.getSelectedPosition();
+ int selectingPosition = indicator.getSelectingPosition();
+ int lastSelectedPosition = indicator.getLastSelectedPosition();
+
+ boolean selectedItem = !interactiveAnimation && (position == selectedPosition || position == lastSelectedPosition);
+ boolean selectingItem = interactiveAnimation && (position == selectedPosition || position == selectingPosition);
+ boolean isSelectedItem = selectedItem | selectingItem;
+ drawer.setup(position, coordinateX, coordinateY);
+
+ if (value != null && isSelectedItem) {
+ drawWithAnimation(canvas);
+ } else {
+ drawer.drawBasic(canvas, isSelectedItem);
+ }
+ }
+
+ private void drawWithAnimation(@NonNull Canvas canvas) {
+ AnimationType animationType = indicator.getAnimationType();
+ switch (animationType) {
+ case NONE:
+ drawer.drawBasic(canvas, true);
+ break;
+
+ case COLOR:
+ drawer.drawColor(canvas, value);
+ break;
+
+ case SCALE:
+ drawer.drawScale(canvas, value);
+ break;
+
+ case WORM:
+ drawer.drawWorm(canvas, value);
+ break;
+
+ case SLIDE:
+ drawer.drawSlide(canvas, value);
+ break;
+
+ case FILL:
+ drawer.drawFill(canvas, value);
+ break;
+
+ case THIN_WORM:
+ drawer.drawThinWorm(canvas, value);
+ break;
+
+ case DROP:
+ drawer.drawDrop(canvas, value);
+ break;
+
+ case SWAP:
+ drawer.drawSwap(canvas, value);
+ break;
+
+ case SCALE_DOWN:
+ drawer.drawScaleDown(canvas, value);
+ break;
+ }
+ }
+}
diff --git a/pageindicatorview/src/main/java/com/rd/draw/controller/MeasureController.java b/pageindicatorview/src/main/java/com/rd/draw/controller/MeasureController.java
new file mode 100644
index 0000000..b5e2ce0
--- /dev/null
+++ b/pageindicatorview/src/main/java/com/rd/draw/controller/MeasureController.java
@@ -0,0 +1,104 @@
+package com.rd.draw.controller;
+
+import androidx.annotation.NonNull;
+import android.util.Pair;
+import android.view.View;
+import com.rd.animation.type.AnimationType;
+import com.rd.draw.data.Indicator;
+import com.rd.draw.data.Orientation;
+
+public class MeasureController {
+
+ public Pair measureViewSize(@NonNull Indicator indicator, int widthMeasureSpec, int heightMeasureSpec) {
+ int widthMode = View.MeasureSpec.getMode(widthMeasureSpec);
+ int widthSize = View.MeasureSpec.getSize(widthMeasureSpec);
+
+ int heightMode = View.MeasureSpec.getMode(heightMeasureSpec);
+ int heightSize = View.MeasureSpec.getSize(heightMeasureSpec);
+
+ int count = indicator.getCount();
+ int radius = indicator.getRadius();
+ int stroke = indicator.getStroke();
+
+ int padding = indicator.getPadding();
+ int paddingLeft = indicator.getPaddingLeft();
+ int paddingTop = indicator.getPaddingTop();
+ int paddingRight = indicator.getPaddingRight();
+ int paddingBottom = indicator.getPaddingBottom();
+
+ int circleDiameterPx = radius * 2;
+ int desiredWidth = 0;
+ int desiredHeight = 0;
+
+ int width;
+ int height;
+
+ Orientation orientation = indicator.getOrientation();
+ if (count != 0) {
+ int diameterSum = circleDiameterPx * count;
+ int strokeSum = (stroke * 2) * count;
+
+ int paddingSum = padding * (count - 1);
+ int w = diameterSum + strokeSum + paddingSum;
+ int h = circleDiameterPx + stroke;
+
+ if (orientation == Orientation.HORIZONTAL) {
+ desiredWidth = w;
+ desiredHeight = h;
+
+ } else {
+ desiredWidth = h;
+ desiredHeight = w;
+ }
+ }
+
+ if (indicator.getAnimationType() == AnimationType.DROP) {
+ if (orientation == Orientation.HORIZONTAL) {
+ desiredHeight *= 2;
+ } else {
+ desiredWidth *= 2;
+ }
+ }
+
+ int horizontalPadding = paddingLeft + paddingRight;
+ int verticalPadding = paddingTop + paddingBottom;
+
+ if (orientation == Orientation.HORIZONTAL) {
+ desiredWidth += horizontalPadding;
+ desiredHeight += verticalPadding;
+
+ } else {
+ desiredWidth += horizontalPadding;
+ desiredHeight += verticalPadding;
+ }
+
+ if (widthMode == View.MeasureSpec.EXACTLY) {
+ width = widthSize;
+ } else if (widthMode == View.MeasureSpec.AT_MOST) {
+ width = Math.min(desiredWidth, widthSize);
+ } else {
+ width = desiredWidth;
+ }
+
+ if (heightMode == View.MeasureSpec.EXACTLY) {
+ height = heightSize;
+ } else if (heightMode == View.MeasureSpec.AT_MOST) {
+ height = Math.min(desiredHeight, heightSize);
+ } else {
+ height = desiredHeight;
+ }
+
+ if (width < 0) {
+ width = 0;
+ }
+
+ if (height < 0) {
+ height = 0;
+ }
+
+ indicator.setWidth(width);
+ indicator.setHeight(height);
+
+ return new Pair<>(width, height);
+ }
+}
diff --git a/pageindicatorview/src/main/java/com/rd/draw/data/Indicator.java b/pageindicatorview/src/main/java/com/rd/draw/data/Indicator.java
new file mode 100644
index 0000000..405f03a
--- /dev/null
+++ b/pageindicatorview/src/main/java/com/rd/draw/data/Indicator.java
@@ -0,0 +1,281 @@
+package com.rd.draw.data;
+
+import androidx.annotation.NonNull;
+import android.view.View;
+import com.rd.animation.type.AnimationType;
+
+public class Indicator {
+
+ public static final int DEFAULT_COUNT = 3;
+ public static final int MIN_COUNT = 1;
+ public static final int COUNT_NONE = -1;
+
+ public static final int DEFAULT_RADIUS_DP = 6;
+ public static final int DEFAULT_PADDING_DP = 8;
+ public static final int IDLE_ANIMATION_DURATION = 250;
+
+ private int height;
+ private int width;
+ private int radius;
+
+ private int padding;
+ private int paddingLeft;
+ private int paddingTop;
+ private int paddingRight;
+ private int paddingBottom;
+
+ private int stroke; //For "Fill" animation only
+ private float scaleFactor; //For "Scale" animation only
+
+ private int unselectedColor;
+ private int selectedColor;
+
+ private boolean interactiveAnimation;
+ private boolean autoVisibility;
+ private boolean dynamicCount;
+
+ private boolean fadeOnIdle;
+ private boolean isIdle;
+ private long idleDuration;
+
+ private long animationDuration;
+ private int count = DEFAULT_COUNT;
+
+ private int selectedPosition;
+ private int selectingPosition;
+ private int lastSelectedPosition;
+
+ private int viewPagerId = View.NO_ID;
+
+ private Orientation orientation;
+ private AnimationType animationType;
+ private RtlMode rtlMode;
+
+ public int getHeight() {
+ return height;
+ }
+
+ public void setHeight(int height) {
+ this.height = height;
+ }
+
+ public int getWidth() {
+ return width;
+ }
+
+ public void setWidth(int width) {
+ this.width = width;
+ }
+
+ public int getRadius() {
+ return radius;
+ }
+
+ public void setRadius(int radius) {
+ this.radius = radius;
+ }
+
+ public int getPadding() {
+ return padding;
+ }
+
+ public void setPadding(int padding) {
+ this.padding = padding;
+ }
+
+ public int getPaddingLeft() {
+ return paddingLeft;
+ }
+
+ public void setPaddingLeft(int paddingLeft) {
+ this.paddingLeft = paddingLeft;
+ }
+
+ public int getPaddingTop() {
+ return paddingTop;
+ }
+
+ public void setPaddingTop(int paddingTop) {
+ this.paddingTop = paddingTop;
+ }
+
+ public int getPaddingRight() {
+ return paddingRight;
+ }
+
+ public void setPaddingRight(int paddingRight) {
+ this.paddingRight = paddingRight;
+ }
+
+ public int getPaddingBottom() {
+ return paddingBottom;
+ }
+
+ public void setPaddingBottom(int paddingBottom) {
+ this.paddingBottom = paddingBottom;
+ }
+
+ public int getStroke() {
+ return stroke;
+ }
+
+ public void setStroke(int stroke) {
+ this.stroke = stroke;
+ }
+
+ public float getScaleFactor() {
+ return scaleFactor;
+ }
+
+ public void setScaleFactor(float scaleFactor) {
+ this.scaleFactor = scaleFactor;
+ }
+
+ public int getUnselectedColor() {
+ return unselectedColor;
+ }
+
+ public void setUnselectedColor(int unselectedColor) {
+ this.unselectedColor = unselectedColor;
+ }
+
+ public int getSelectedColor() {
+ return selectedColor;
+ }
+
+ public void setSelectedColor(int selectedColor) {
+ this.selectedColor = selectedColor;
+ }
+
+ public boolean isInteractiveAnimation() {
+ return interactiveAnimation;
+ }
+
+ public void setInteractiveAnimation(boolean interactiveAnimation) {
+ this.interactiveAnimation = interactiveAnimation;
+ }
+
+ public boolean isAutoVisibility() {
+ return autoVisibility;
+ }
+
+ public void setAutoVisibility(boolean autoVisibility) {
+ this.autoVisibility = autoVisibility;
+ }
+
+ public boolean isDynamicCount() {
+ return dynamicCount;
+ }
+
+ public void setDynamicCount(boolean dynamicCount) {
+ this.dynamicCount = dynamicCount;
+ }
+
+ public boolean isFadeOnIdle() {
+ return fadeOnIdle;
+ }
+
+ public void setFadeOnIdle(boolean fadeOnIdle) {
+ this.fadeOnIdle = fadeOnIdle;
+ }
+
+ public boolean isIdle() {
+ return isIdle;
+ }
+
+ public void setIdle(boolean idle) {
+ isIdle = idle;
+ }
+
+ public long getIdleDuration() {
+ return idleDuration;
+ }
+
+ public void setIdleDuration(long idleDuration) {
+ this.idleDuration = idleDuration;
+ }
+
+ public long getAnimationDuration() {
+ return animationDuration;
+ }
+
+ public void setAnimationDuration(long animationDuration) {
+ this.animationDuration = animationDuration;
+ }
+
+ public int getCount() {
+ return count;
+ }
+
+ public void setCount(int count) {
+ this.count = count;
+ }
+
+ public int getSelectedPosition() {
+ return selectedPosition;
+ }
+
+ public void setSelectedPosition(int selectedPosition) {
+ this.selectedPosition = selectedPosition;
+ }
+
+ public int getSelectingPosition() {
+ return selectingPosition;
+ }
+
+ public void setSelectingPosition(int selectingPosition) {
+ this.selectingPosition = selectingPosition;
+ }
+
+ public int getLastSelectedPosition() {
+ return lastSelectedPosition;
+ }
+
+ public void setLastSelectedPosition(int lastSelectedPosition) {
+ this.lastSelectedPosition = lastSelectedPosition;
+ }
+
+ public int getViewPagerId() {
+ return viewPagerId;
+ }
+
+ public void setViewPagerId(int viewPagerId) {
+ this.viewPagerId = viewPagerId;
+ }
+
+ @NonNull
+ public Orientation getOrientation() {
+ if (orientation == null) {
+ orientation = Orientation.HORIZONTAL;
+ }
+ return orientation;
+ }
+
+ public void setOrientation(Orientation orientation) {
+ this.orientation = orientation;
+ }
+
+ @NonNull
+ public AnimationType getAnimationType() {
+ if (animationType == null) {
+ animationType = AnimationType.NONE;
+ }
+ return animationType;
+ }
+
+ public void setAnimationType(AnimationType animationType) {
+ this.animationType = animationType;
+ }
+
+ @NonNull
+ public RtlMode getRtlMode() {
+ if (rtlMode == null) {
+ rtlMode = RtlMode.Off;
+ }
+ return rtlMode;
+ }
+
+ public void setRtlMode(RtlMode rtlMode) {
+ this.rtlMode = rtlMode;
+ }
+}
diff --git a/pageindicatorview/src/main/java/com/rd/draw/data/Orientation.java b/pageindicatorview/src/main/java/com/rd/draw/data/Orientation.java
new file mode 100644
index 0000000..922acef
--- /dev/null
+++ b/pageindicatorview/src/main/java/com/rd/draw/data/Orientation.java
@@ -0,0 +1,3 @@
+package com.rd.draw.data;
+
+public enum Orientation {HORIZONTAL, VERTICAL}
diff --git a/pageindicatorview/src/main/java/com/rd/draw/data/PositionSavedState.java b/pageindicatorview/src/main/java/com/rd/draw/data/PositionSavedState.java
new file mode 100644
index 0000000..ba93911
--- /dev/null
+++ b/pageindicatorview/src/main/java/com/rd/draw/data/PositionSavedState.java
@@ -0,0 +1,65 @@
+package com.rd.draw.data;
+
+import android.os.Parcel;
+import android.os.Parcelable;
+import android.view.View;
+
+public class PositionSavedState extends View.BaseSavedState {
+
+ private int selectedPosition;
+ private int selectingPosition;
+ private int lastSelectedPosition;
+
+ public PositionSavedState(Parcelable superState) {
+ super(superState);
+ }
+
+ private PositionSavedState(Parcel in) {
+ super(in);
+ this.selectedPosition = in.readInt();
+ this.selectingPosition = in.readInt();
+ this.lastSelectedPosition = in.readInt();
+ }
+
+ public int getSelectedPosition() {
+ return selectedPosition;
+ }
+
+ public void setSelectedPosition(int selectedPosition) {
+ this.selectedPosition = selectedPosition;
+ }
+
+ public int getSelectingPosition() {
+ return selectingPosition;
+ }
+
+ public void setSelectingPosition(int selectingPosition) {
+ this.selectingPosition = selectingPosition;
+ }
+
+ public int getLastSelectedPosition() {
+ return lastSelectedPosition;
+ }
+
+ public void setLastSelectedPosition(int lastSelectedPosition) {
+ this.lastSelectedPosition = lastSelectedPosition;
+ }
+
+ @Override
+ public void writeToParcel(Parcel out, int flags) {
+ super.writeToParcel(out, flags);
+ out.writeInt(this.selectedPosition);
+ out.writeInt(this.selectingPosition);
+ out.writeInt(this.lastSelectedPosition);
+ }
+
+ public static final Parcelable.Creator CREATOR = new Parcelable.Creator() {
+ public PositionSavedState createFromParcel(Parcel in) {
+ return new PositionSavedState(in);
+ }
+
+ public PositionSavedState[] newArray(int size) {
+ return new PositionSavedState[size];
+ }
+ };
+}
diff --git a/pageindicatorview/src/main/java/com/rd/draw/data/RtlMode.java b/pageindicatorview/src/main/java/com/rd/draw/data/RtlMode.java
new file mode 100644
index 0000000..506c889
--- /dev/null
+++ b/pageindicatorview/src/main/java/com/rd/draw/data/RtlMode.java
@@ -0,0 +1,3 @@
+package com.rd.draw.data;
+
+public enum RtlMode {On, Off, Auto}
\ No newline at end of file
diff --git a/pageindicatorview/src/main/java/com/rd/draw/drawer/Drawer.java b/pageindicatorview/src/main/java/com/rd/draw/drawer/Drawer.java
new file mode 100644
index 0000000..3ad9d5d
--- /dev/null
+++ b/pageindicatorview/src/main/java/com/rd/draw/drawer/Drawer.java
@@ -0,0 +1,109 @@
+package com.rd.draw.drawer;
+
+import android.graphics.Canvas;
+import android.graphics.Paint;
+import androidx.annotation.NonNull;
+import com.rd.animation.data.Value;
+import com.rd.draw.data.Indicator;
+import com.rd.draw.drawer.type.*;
+
+public class Drawer {
+
+ private BasicDrawer basicDrawer;
+ private ColorDrawer colorDrawer;
+ private ScaleDrawer scaleDrawer;
+ private WormDrawer wormDrawer;
+ private SlideDrawer slideDrawer;
+ private FillDrawer fillDrawer;
+ private ThinWormDrawer thinWormDrawer;
+ private DropDrawer dropDrawer;
+ private SwapDrawer swapDrawer;
+ private ScaleDownDrawer scaleDownDrawer;
+
+ private int position;
+ private int coordinateX;
+ private int coordinateY;
+
+ public Drawer(@NonNull Indicator indicator) {
+ Paint paint = new Paint();
+ paint.setStyle(Paint.Style.FILL);
+ paint.setAntiAlias(true);
+
+ basicDrawer = new BasicDrawer(paint, indicator);
+ colorDrawer = new ColorDrawer(paint, indicator);
+ scaleDrawer = new ScaleDrawer(paint, indicator);
+ wormDrawer = new WormDrawer(paint, indicator);
+ slideDrawer = new SlideDrawer(paint, indicator);
+ fillDrawer = new FillDrawer(paint, indicator);
+ thinWormDrawer = new ThinWormDrawer(paint, indicator);
+ dropDrawer = new DropDrawer(paint, indicator);
+ swapDrawer = new SwapDrawer(paint, indicator);
+ scaleDownDrawer = new ScaleDownDrawer(paint, indicator);
+ }
+
+ public void setup(int position, int coordinateX, int coordinateY) {
+ this.position = position;
+ this.coordinateX = coordinateX;
+ this.coordinateY = coordinateY;
+ }
+
+ public void drawBasic(@NonNull Canvas canvas, boolean isSelectedItem) {
+ if (colorDrawer != null) {
+ basicDrawer.draw(canvas, position, isSelectedItem, coordinateX, coordinateY);
+ }
+ }
+
+ public void drawColor(@NonNull Canvas canvas, @NonNull Value value) {
+ if (colorDrawer != null) {
+ colorDrawer.draw(canvas, value, position, coordinateX, coordinateY);
+ }
+ }
+
+ public void drawScale(@NonNull Canvas canvas, @NonNull Value value) {
+ if (scaleDrawer != null) {
+ scaleDrawer.draw(canvas, value, position, coordinateX, coordinateY);
+ }
+ }
+
+ public void drawWorm(@NonNull Canvas canvas, @NonNull Value value) {
+ if (wormDrawer != null) {
+ wormDrawer.draw(canvas, value, coordinateX, coordinateY);
+ }
+ }
+
+ public void drawSlide(@NonNull Canvas canvas, @NonNull Value value) {
+ if (slideDrawer != null) {
+ slideDrawer.draw(canvas, value, coordinateX, coordinateY);
+ }
+ }
+
+ public void drawFill(@NonNull Canvas canvas, @NonNull Value value) {
+ if (fillDrawer != null) {
+ fillDrawer.draw(canvas, value, position, coordinateX, coordinateY);
+ }
+ }
+
+ public void drawThinWorm(@NonNull Canvas canvas, @NonNull Value value) {
+ if (thinWormDrawer != null) {
+ thinWormDrawer.draw(canvas, value, coordinateX, coordinateY);
+ }
+ }
+
+ public void drawDrop(@NonNull Canvas canvas, @NonNull Value value) {
+ if (dropDrawer != null) {
+ dropDrawer.draw(canvas, value, coordinateX, coordinateY);
+ }
+ }
+
+ public void drawSwap(@NonNull Canvas canvas, @NonNull Value value) {
+ if (swapDrawer != null) {
+ swapDrawer.draw(canvas, value, position, coordinateX, coordinateY);
+ }
+ }
+
+ public void drawScaleDown(@NonNull Canvas canvas, @NonNull Value value) {
+ if (scaleDownDrawer != null) {
+ scaleDownDrawer.draw(canvas, value, position, coordinateX, coordinateY);
+ }
+ }
+}
diff --git a/pageindicatorview/src/main/java/com/rd/draw/drawer/type/BaseDrawer.java b/pageindicatorview/src/main/java/com/rd/draw/drawer/type/BaseDrawer.java
new file mode 100644
index 0000000..d5953a3
--- /dev/null
+++ b/pageindicatorview/src/main/java/com/rd/draw/drawer/type/BaseDrawer.java
@@ -0,0 +1,16 @@
+package com.rd.draw.drawer.type;
+
+import android.graphics.Paint;
+import androidx.annotation.NonNull;
+import com.rd.draw.data.Indicator;
+
+class BaseDrawer {
+
+ Paint paint;
+ Indicator indicator;
+
+ BaseDrawer(@NonNull Paint paint, @NonNull Indicator indicator) {
+ this.paint = paint;
+ this.indicator = indicator;
+ }
+}
diff --git a/pageindicatorview/src/main/java/com/rd/draw/drawer/type/BasicDrawer.java b/pageindicatorview/src/main/java/com/rd/draw/drawer/type/BasicDrawer.java
new file mode 100644
index 0000000..921ee54
--- /dev/null
+++ b/pageindicatorview/src/main/java/com/rd/draw/drawer/type/BasicDrawer.java
@@ -0,0 +1,61 @@
+package com.rd.draw.drawer.type;
+
+import android.graphics.Canvas;
+import android.graphics.Paint;
+import androidx.annotation.NonNull;
+import com.rd.animation.type.AnimationType;
+import com.rd.draw.data.Indicator;
+
+public class BasicDrawer extends BaseDrawer {
+
+ private Paint strokePaint;
+
+ public BasicDrawer(@NonNull Paint paint, @NonNull Indicator indicator) {
+ super(paint, indicator);
+
+ strokePaint = new Paint();
+ strokePaint.setStyle(Paint.Style.STROKE);
+ strokePaint.setAntiAlias(true);
+ strokePaint.setStrokeWidth(indicator.getStroke());
+ }
+
+ public void draw(
+ @NonNull Canvas canvas,
+ int position,
+ boolean isSelectedItem,
+ int coordinateX,
+ int coordinateY) {
+
+ float radius = indicator.getRadius();
+ int strokePx = indicator.getStroke();
+ float scaleFactor = indicator.getScaleFactor();
+
+ int selectedColor = indicator.getSelectedColor();
+ int unselectedColor = indicator.getUnselectedColor();
+ int selectedPosition = indicator.getSelectedPosition();
+ AnimationType animationType = indicator.getAnimationType();
+
+ if (animationType == AnimationType.SCALE && !isSelectedItem) {
+ radius *= scaleFactor;
+
+ } else if (animationType == AnimationType.SCALE_DOWN && isSelectedItem) {
+ radius *= scaleFactor;
+ }
+
+ int color = unselectedColor;
+ if (position == selectedPosition) {
+ color = selectedColor;
+ }
+
+ Paint paint;
+ if (animationType == AnimationType.FILL && position != selectedPosition) {
+ paint = strokePaint;
+ paint.setStrokeWidth(strokePx);
+ } else {
+ paint = this.paint;
+ }
+
+ paint.setColor(color);
+ canvas.drawCircle(coordinateX, coordinateY, radius, paint);
+ }
+}
diff --git a/pageindicatorview/src/main/java/com/rd/draw/drawer/type/ColorDrawer.java b/pageindicatorview/src/main/java/com/rd/draw/drawer/type/ColorDrawer.java
new file mode 100644
index 0000000..513fd8b
--- /dev/null
+++ b/pageindicatorview/src/main/java/com/rd/draw/drawer/type/ColorDrawer.java
@@ -0,0 +1,54 @@
+package com.rd.draw.drawer.type;
+
+import android.graphics.Canvas;
+import android.graphics.Paint;
+import androidx.annotation.NonNull;
+import com.rd.animation.data.Value;
+import com.rd.animation.data.type.ColorAnimationValue;
+import com.rd.draw.data.Indicator;
+
+public class ColorDrawer extends BaseDrawer {
+
+ public ColorDrawer(@NonNull Paint paint, @NonNull Indicator indicator) {
+ super(paint, indicator);
+ }
+
+ public void draw(@NonNull Canvas canvas,
+ @NonNull Value value,
+ int position,
+ int coordinateX,
+ int coordinateY) {
+
+ if (!(value instanceof ColorAnimationValue)) {
+ return;
+ }
+
+ ColorAnimationValue v = (ColorAnimationValue) value;
+ float radius = indicator.getRadius();
+ int color = indicator.getSelectedColor();
+
+ int selectedPosition = indicator.getSelectedPosition();
+ int selectingPosition = indicator.getSelectingPosition();
+ int lastSelectedPosition = indicator.getLastSelectedPosition();
+
+ if (indicator.isInteractiveAnimation()) {
+ if (position == selectingPosition) {
+ color = v.getColor();
+
+ } else if (position == selectedPosition) {
+ color = v.getColorReverse();
+ }
+
+ } else {
+ if (position == selectedPosition) {
+ color = v.getColor();
+
+ } else if (position == lastSelectedPosition) {
+ color = v.getColorReverse();
+ }
+ }
+
+ paint.setColor(color);
+ canvas.drawCircle(coordinateX, coordinateY, radius, paint);
+ }
+}
diff --git a/pageindicatorview/src/main/java/com/rd/draw/drawer/type/DropDrawer.java b/pageindicatorview/src/main/java/com/rd/draw/drawer/type/DropDrawer.java
new file mode 100644
index 0000000..3f7969e
--- /dev/null
+++ b/pageindicatorview/src/main/java/com/rd/draw/drawer/type/DropDrawer.java
@@ -0,0 +1,42 @@
+package com.rd.draw.drawer.type;
+
+import android.graphics.Canvas;
+import android.graphics.Paint;
+import androidx.annotation.NonNull;
+import com.rd.animation.data.Value;
+import com.rd.animation.data.type.DropAnimationValue;
+import com.rd.draw.data.Indicator;
+import com.rd.draw.data.Orientation;
+
+public class DropDrawer extends BaseDrawer {
+
+ public DropDrawer(@NonNull Paint paint, @NonNull Indicator indicator) {
+ super(paint, indicator);
+ }
+
+ public void draw(
+ @NonNull Canvas canvas,
+ @NonNull Value value,
+ int coordinateX,
+ int coordinateY) {
+
+ if (!(value instanceof DropAnimationValue)) {
+ return;
+ }
+
+ DropAnimationValue v = (DropAnimationValue) value;
+ int unselectedColor = indicator.getUnselectedColor();
+ int selectedColor = indicator.getSelectedColor();
+ float radius = indicator.getRadius();
+
+ paint.setColor(unselectedColor);
+ canvas.drawCircle(coordinateX, coordinateY, radius, paint);
+
+ paint.setColor(selectedColor);
+ if (indicator.getOrientation() == Orientation.HORIZONTAL) {
+ canvas.drawCircle(v.getWidth(), v.getHeight(), v.getRadius(), paint);
+ } else {
+ canvas.drawCircle(v.getHeight(), v.getWidth(), v.getRadius(), paint);
+ }
+ }
+}
diff --git a/pageindicatorview/src/main/java/com/rd/draw/drawer/type/FillDrawer.java b/pageindicatorview/src/main/java/com/rd/draw/drawer/type/FillDrawer.java
new file mode 100644
index 0000000..226a388
--- /dev/null
+++ b/pageindicatorview/src/main/java/com/rd/draw/drawer/type/FillDrawer.java
@@ -0,0 +1,74 @@
+package com.rd.draw.drawer.type;
+
+import android.graphics.Canvas;
+import android.graphics.Paint;
+import androidx.annotation.NonNull;
+import com.rd.animation.data.Value;
+import com.rd.animation.data.type.FillAnimationValue;
+import com.rd.draw.data.Indicator;
+
+public class FillDrawer extends BaseDrawer {
+
+ private Paint strokePaint;
+
+ public FillDrawer(@NonNull Paint paint, @NonNull Indicator indicator) {
+ super(paint, indicator);
+
+ strokePaint = new Paint();
+ strokePaint.setStyle(Paint.Style.STROKE);
+ strokePaint.setAntiAlias(true);
+ }
+
+ public void draw(
+ @NonNull Canvas canvas,
+ @NonNull Value value,
+ int position,
+ int coordinateX,
+ int coordinateY) {
+
+ if (!(value instanceof FillAnimationValue)) {
+ return;
+ }
+
+ FillAnimationValue v = (FillAnimationValue) value;
+ int color = indicator.getUnselectedColor();
+ float radius = indicator.getRadius();
+ int stroke = indicator.getStroke();
+
+ int selectedPosition = indicator.getSelectedPosition();
+ int selectingPosition = indicator.getSelectingPosition();
+ int lastSelectedPosition = indicator.getLastSelectedPosition();
+
+ if (indicator.isInteractiveAnimation()) {
+ if (position == selectingPosition) {
+ color = v.getColor();
+ radius = v.getRadius();
+ stroke = v.getStroke();
+
+ } else if (position == selectedPosition) {
+ color = v.getColorReverse();
+ radius = v.getRadiusReverse();
+ stroke = v.getStrokeReverse();
+ }
+
+ } else {
+ if (position == selectedPosition) {
+ color = v.getColor();
+ radius = v.getRadius();
+ stroke = v.getStroke();
+
+ } else if (position == lastSelectedPosition) {
+ color = v.getColorReverse();
+ radius = v.getRadiusReverse();
+ stroke = v.getStrokeReverse();
+ }
+ }
+
+ strokePaint.setColor(color);
+ strokePaint.setStrokeWidth(indicator.getStroke());
+ canvas.drawCircle(coordinateX, coordinateY, indicator.getRadius(), strokePaint);
+
+ strokePaint.setStrokeWidth(stroke);
+ canvas.drawCircle(coordinateX, coordinateY, radius, strokePaint);
+ }
+}
diff --git a/pageindicatorview/src/main/java/com/rd/draw/drawer/type/ScaleDownDrawer.java b/pageindicatorview/src/main/java/com/rd/draw/drawer/type/ScaleDownDrawer.java
new file mode 100644
index 0000000..6389b41
--- /dev/null
+++ b/pageindicatorview/src/main/java/com/rd/draw/drawer/type/ScaleDownDrawer.java
@@ -0,0 +1,59 @@
+package com.rd.draw.drawer.type;
+
+import android.graphics.Canvas;
+import android.graphics.Paint;
+import androidx.annotation.NonNull;
+import com.rd.animation.data.Value;
+import com.rd.animation.data.type.ScaleAnimationValue;
+import com.rd.draw.data.Indicator;
+
+public class ScaleDownDrawer extends BaseDrawer {
+
+ public ScaleDownDrawer(@NonNull Paint paint, @NonNull Indicator indicator) {
+ super(paint, indicator);
+ }
+
+ public void draw(
+ @NonNull Canvas canvas,
+ @NonNull Value value,
+ int position,
+ int coordinateX,
+ int coordinateY) {
+
+ if (!(value instanceof ScaleAnimationValue)) {
+ return;
+ }
+
+ ScaleAnimationValue v = (ScaleAnimationValue) value;
+ float radius = indicator.getRadius();
+ int color = indicator.getSelectedColor();
+
+ int selectedPosition = indicator.getSelectedPosition();
+ int selectingPosition = indicator.getSelectingPosition();
+ int lastSelectedPosition = indicator.getLastSelectedPosition();
+
+ if (indicator.isInteractiveAnimation()) {
+ if (position == selectingPosition) {
+ radius = v.getRadius();
+ color = v.getColor();
+
+ } else if (position == selectedPosition) {
+ radius = v.getRadiusReverse();
+ color = v.getColorReverse();
+ }
+
+ } else {
+ if (position == selectedPosition) {
+ radius = v.getRadius();
+ color = v.getColor();
+
+ } else if (position == lastSelectedPosition) {
+ radius = v.getRadiusReverse();
+ color = v.getColorReverse();
+ }
+ }
+
+ paint.setColor(color);
+ canvas.drawCircle(coordinateX, coordinateY, radius, paint);
+ }
+}
diff --git a/pageindicatorview/src/main/java/com/rd/draw/drawer/type/ScaleDrawer.java b/pageindicatorview/src/main/java/com/rd/draw/drawer/type/ScaleDrawer.java
new file mode 100644
index 0000000..f1f2bb3
--- /dev/null
+++ b/pageindicatorview/src/main/java/com/rd/draw/drawer/type/ScaleDrawer.java
@@ -0,0 +1,59 @@
+package com.rd.draw.drawer.type;
+
+import android.graphics.Canvas;
+import android.graphics.Paint;
+import androidx.annotation.NonNull;
+import com.rd.animation.data.Value;
+import com.rd.animation.data.type.ScaleAnimationValue;
+import com.rd.draw.data.Indicator;
+
+public class ScaleDrawer extends BaseDrawer {
+
+ public ScaleDrawer(@NonNull Paint paint, @NonNull Indicator indicator) {
+ super(paint, indicator);
+ }
+
+ public void draw(
+ @NonNull Canvas canvas,
+ @NonNull Value value,
+ int position,
+ int coordinateX,
+ int coordinateY) {
+
+ if (!(value instanceof ScaleAnimationValue)) {
+ return;
+ }
+
+ ScaleAnimationValue v = (ScaleAnimationValue) value;
+ float radius = indicator.getRadius();
+ int color = indicator.getSelectedColor();
+
+ int selectedPosition = indicator.getSelectedPosition();
+ int selectingPosition = indicator.getSelectingPosition();
+ int lastSelectedPosition = indicator.getLastSelectedPosition();
+
+ if (indicator.isInteractiveAnimation()) {
+ if (position == selectingPosition) {
+ radius = v.getRadius();
+ color = v.getColor();
+
+ } else if (position == selectedPosition) {
+ radius = v.getRadiusReverse();
+ color = v.getColorReverse();
+ }
+
+ } else {
+ if (position == selectedPosition) {
+ radius = v.getRadius();
+ color = v.getColor();
+
+ } else if (position == lastSelectedPosition) {
+ radius = v.getRadiusReverse();
+ color = v.getColorReverse();
+ }
+ }
+
+ paint.setColor(color);
+ canvas.drawCircle(coordinateX, coordinateY, radius, paint);
+ }
+}
diff --git a/pageindicatorview/src/main/java/com/rd/draw/drawer/type/SlideDrawer.java b/pageindicatorview/src/main/java/com/rd/draw/drawer/type/SlideDrawer.java
new file mode 100644
index 0000000..0e9118a
--- /dev/null
+++ b/pageindicatorview/src/main/java/com/rd/draw/drawer/type/SlideDrawer.java
@@ -0,0 +1,43 @@
+package com.rd.draw.drawer.type;
+
+import android.graphics.Canvas;
+import android.graphics.Paint;
+import androidx.annotation.NonNull;
+import com.rd.animation.data.Value;
+import com.rd.animation.data.type.SlideAnimationValue;
+import com.rd.draw.data.Indicator;
+import com.rd.draw.data.Orientation;
+
+public class SlideDrawer extends BaseDrawer {
+
+ public SlideDrawer(@NonNull Paint paint, @NonNull Indicator indicator) {
+ super(paint, indicator);
+ }
+
+ public void draw(
+ @NonNull Canvas canvas,
+ @NonNull Value value,
+ int coordinateX,
+ int coordinateY) {
+
+ if (!(value instanceof SlideAnimationValue)) {
+ return;
+ }
+
+ SlideAnimationValue v = (SlideAnimationValue) value;
+ int coordinate = v.getCoordinate();
+ int unselectedColor = indicator.getUnselectedColor();
+ int selectedColor = indicator.getSelectedColor();
+ int radius = indicator.getRadius();
+
+ paint.setColor(unselectedColor);
+ canvas.drawCircle(coordinateX, coordinateY, radius, paint);
+
+ paint.setColor(selectedColor);
+ if (indicator.getOrientation() == Orientation.HORIZONTAL) {
+ canvas.drawCircle(coordinate, coordinateY, radius, paint);
+ } else {
+ canvas.drawCircle(coordinateX, coordinate, radius, paint);
+ }
+ }
+}
diff --git a/pageindicatorview/src/main/java/com/rd/draw/drawer/type/SwapDrawer.java b/pageindicatorview/src/main/java/com/rd/draw/drawer/type/SwapDrawer.java
new file mode 100644
index 0000000..77d8b19
--- /dev/null
+++ b/pageindicatorview/src/main/java/com/rd/draw/drawer/type/SwapDrawer.java
@@ -0,0 +1,68 @@
+package com.rd.draw.drawer.type;
+
+import android.graphics.Canvas;
+import android.graphics.Paint;
+import androidx.annotation.NonNull;
+import com.rd.animation.data.Value;
+import com.rd.animation.data.type.SwapAnimationValue;
+import com.rd.draw.data.Indicator;
+import com.rd.draw.data.Orientation;
+
+public class SwapDrawer extends BaseDrawer {
+
+ public SwapDrawer(@NonNull Paint paint, @NonNull Indicator indicator) {
+ super(paint, indicator);
+ }
+
+ public void draw(
+ @NonNull Canvas canvas,
+ @NonNull Value value,
+ int position,
+ int coordinateX,
+ int coordinateY) {
+
+ if (!(value instanceof SwapAnimationValue)) {
+ return;
+ }
+
+ SwapAnimationValue v = (SwapAnimationValue) value;
+ int selectedColor = indicator.getSelectedColor();
+ int unselectedColor = indicator.getUnselectedColor();
+ int radius = indicator.getRadius();
+
+ int selectedPosition = indicator.getSelectedPosition();
+ int selectingPosition = indicator.getSelectingPosition();
+ int lastSelectedPosition = indicator.getLastSelectedPosition();
+
+ int coordinate = v.getCoordinate();
+ int color = unselectedColor;
+
+ if (indicator.isInteractiveAnimation()) {
+ if (position == selectingPosition) {
+ coordinate = v.getCoordinate();
+ color = selectedColor;
+
+ } else if (position == selectedPosition) {
+ coordinate = v.getCoordinateReverse();
+ color = unselectedColor;
+ }
+
+ } else {
+ if (position == lastSelectedPosition) {
+ coordinate = v.getCoordinate();
+ color = selectedColor;
+
+ } else if (position == selectedPosition) {
+ coordinate = v.getCoordinateReverse();
+ color = unselectedColor;
+ }
+ }
+
+ paint.setColor(color);
+ if (indicator.getOrientation() == Orientation.HORIZONTAL) {
+ canvas.drawCircle(coordinate, coordinateY, radius, paint);
+ } else {
+ canvas.drawCircle(coordinateX, coordinate, radius, paint);
+ }
+ }
+}
diff --git a/pageindicatorview/src/main/java/com/rd/draw/drawer/type/ThinWormDrawer.java b/pageindicatorview/src/main/java/com/rd/draw/drawer/type/ThinWormDrawer.java
new file mode 100644
index 0000000..670257f
--- /dev/null
+++ b/pageindicatorview/src/main/java/com/rd/draw/drawer/type/ThinWormDrawer.java
@@ -0,0 +1,55 @@
+package com.rd.draw.drawer.type;
+
+import android.graphics.Canvas;
+import android.graphics.Paint;
+import androidx.annotation.NonNull;
+import com.rd.animation.data.Value;
+import com.rd.animation.data.type.ThinWormAnimationValue;
+import com.rd.draw.data.Indicator;
+import com.rd.draw.data.Orientation;
+
+public class ThinWormDrawer extends WormDrawer {
+
+ public ThinWormDrawer(@NonNull Paint paint, @NonNull Indicator indicator) {
+ super(paint, indicator);
+ }
+
+ public void draw(
+ @NonNull Canvas canvas,
+ @NonNull Value value,
+ int coordinateX,
+ int coordinateY) {
+
+ if (!(value instanceof ThinWormAnimationValue)) {
+ return;
+ }
+
+ ThinWormAnimationValue v = (ThinWormAnimationValue) value;
+ int rectStart = v.getRectStart();
+ int rectEnd = v.getRectEnd();
+ int height = v.getHeight() / 2;
+
+ int radius = indicator.getRadius();
+ int unselectedColor = indicator.getUnselectedColor();
+ int selectedColor = indicator.getSelectedColor();
+
+ if (indicator.getOrientation() == Orientation.HORIZONTAL) {
+ rect.left = rectStart;
+ rect.right = rectEnd;
+ rect.top = coordinateY - height;
+ rect.bottom = coordinateY + height;
+
+ } else {
+ rect.left = coordinateX - height;
+ rect.right = coordinateX + height;
+ rect.top = rectStart;
+ rect.bottom = rectEnd;
+ }
+
+ paint.setColor(unselectedColor);
+ canvas.drawCircle(coordinateX, coordinateY, radius, paint);
+
+ paint.setColor(selectedColor);
+ canvas.drawRoundRect(rect, radius, radius, paint);
+ }
+}
diff --git a/pageindicatorview/src/main/java/com/rd/draw/drawer/type/WormDrawer.java b/pageindicatorview/src/main/java/com/rd/draw/drawer/type/WormDrawer.java
new file mode 100644
index 0000000..d0303c1
--- /dev/null
+++ b/pageindicatorview/src/main/java/com/rd/draw/drawer/type/WormDrawer.java
@@ -0,0 +1,58 @@
+package com.rd.draw.drawer.type;
+
+import android.graphics.Canvas;
+import android.graphics.Paint;
+import android.graphics.RectF;
+import androidx.annotation.NonNull;
+import com.rd.animation.data.Value;
+import com.rd.animation.data.type.WormAnimationValue;
+import com.rd.draw.data.Indicator;
+import com.rd.draw.data.Orientation;
+
+public class WormDrawer extends BaseDrawer {
+
+ public RectF rect;
+
+ public WormDrawer(@NonNull Paint paint, @NonNull Indicator indicator) {
+ super(paint, indicator);
+ rect = new RectF();
+ }
+
+ public void draw(
+ @NonNull Canvas canvas,
+ @NonNull Value value,
+ int coordinateX,
+ int coordinateY) {
+
+ if (!(value instanceof WormAnimationValue)) {
+ return;
+ }
+
+ WormAnimationValue v = (WormAnimationValue) value;
+ int rectStart = v.getRectStart();
+ int rectEnd = v.getRectEnd();
+
+ int radius = indicator.getRadius();
+ int unselectedColor = indicator.getUnselectedColor();
+ int selectedColor = indicator.getSelectedColor();
+
+ if (indicator.getOrientation() == Orientation.HORIZONTAL) {
+ rect.left = rectStart;
+ rect.right = rectEnd;
+ rect.top = coordinateY - radius;
+ rect.bottom = coordinateY + radius;
+
+ } else {
+ rect.left = coordinateX - radius;
+ rect.right = coordinateX + radius;
+ rect.top = rectStart;
+ rect.bottom = rectEnd;
+ }
+
+ paint.setColor(unselectedColor);
+ canvas.drawCircle(coordinateX, coordinateY, radius, paint);
+
+ paint.setColor(selectedColor);
+ canvas.drawRoundRect(rect, radius, radius, paint);
+ }
+}
diff --git a/pageindicatorview/src/main/java/com/rd/utils/CoordinatesUtils.java b/pageindicatorview/src/main/java/com/rd/utils/CoordinatesUtils.java
new file mode 100644
index 0000000..a348a4b
--- /dev/null
+++ b/pageindicatorview/src/main/java/com/rd/utils/CoordinatesUtils.java
@@ -0,0 +1,193 @@
+package com.rd.utils;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import android.util.Pair;
+import com.rd.animation.type.AnimationType;
+import com.rd.draw.data.Indicator;
+import com.rd.draw.data.Orientation;
+
+public class CoordinatesUtils {
+
+ @SuppressWarnings("UnnecessaryLocalVariable")
+ public static int getCoordinate(@Nullable Indicator indicator, int position) {
+ if (indicator == null) {
+ return 0;
+ }
+
+ if (indicator.getOrientation() == Orientation.HORIZONTAL) {
+ return getXCoordinate(indicator, position);
+ } else {
+ return getYCoordinate(indicator, position);
+ }
+ }
+
+ @SuppressWarnings("UnnecessaryLocalVariable")
+ public static int getXCoordinate(@Nullable Indicator indicator, int position) {
+ if (indicator == null) {
+ return 0;
+ }
+
+ int coordinate;
+ if (indicator.getOrientation() == Orientation.HORIZONTAL) {
+ coordinate = getHorizontalCoordinate(indicator, position);
+ } else {
+ coordinate = getVerticalCoordinate(indicator);
+ }
+
+ coordinate += indicator.getPaddingLeft();
+ return coordinate;
+ }
+
+ public static int getYCoordinate(@Nullable Indicator indicator, int position) {
+ if (indicator == null) {
+ return 0;
+ }
+
+ int coordinate;
+ if (indicator.getOrientation() == Orientation.HORIZONTAL) {
+ coordinate = getVerticalCoordinate(indicator);
+ } else {
+ coordinate = getHorizontalCoordinate(indicator, position);
+ }
+
+ coordinate += indicator.getPaddingTop();
+ return coordinate;
+ }
+
+ @SuppressWarnings("SuspiciousNameCombination")
+ public static int getPosition(@Nullable Indicator indicator, float x, float y) {
+ if (indicator == null) {
+ return -1;
+ }
+
+ float lengthCoordinate;
+ float heightCoordinate;
+
+ if (indicator.getOrientation() == Orientation.HORIZONTAL) {
+ lengthCoordinate = x;
+ heightCoordinate = y;
+ } else {
+ lengthCoordinate = y;
+ heightCoordinate = x;
+ }
+
+ return getFitPosition(indicator, lengthCoordinate, heightCoordinate);
+ }
+
+ private static int getFitPosition(@NonNull Indicator indicator, float lengthCoordinate, float heightCoordinate) {
+ int count = indicator.getCount();
+ int radius = indicator.getRadius();
+ int stroke = indicator.getStroke();
+ int padding = indicator.getPadding();
+
+ int height = indicator.getOrientation() == Orientation.HORIZONTAL ? indicator.getHeight() : indicator.getWidth();
+ int length = 0;
+
+ for (int i = 0; i < count; i++) {
+ int indicatorPadding = i > 0 ? padding : padding / 2;
+ int startValue = length;
+
+ length += radius * 2 + (stroke / 2) + indicatorPadding;
+ int endValue = length;
+
+ boolean fitLength = lengthCoordinate >= startValue && lengthCoordinate <= endValue;
+ boolean fitHeight = heightCoordinate >= 0 && heightCoordinate <= height;
+
+ if (fitLength && fitHeight) {
+ return i;
+ }
+ }
+
+ return -1;
+ }
+
+ private static int getHorizontalCoordinate(@NonNull Indicator indicator, int position) {
+ int count = indicator.getCount();
+ int radius = indicator.getRadius();
+ int stroke = indicator.getStroke();
+ int padding = indicator.getPadding();
+
+ int coordinate = 0;
+ for (int i = 0; i < count; i++) {
+ coordinate += radius + (stroke / 2);
+
+ if (position == i) {
+ return coordinate;
+ }
+
+ coordinate += radius + padding + (stroke / 2);
+ }
+
+ if (indicator.getAnimationType() == AnimationType.DROP) {
+ coordinate += radius * 2;
+ }
+
+ return coordinate;
+ }
+
+ private static int getVerticalCoordinate(@NonNull Indicator indicator) {
+ int radius = indicator.getRadius();
+ int coordinate;
+
+ if (indicator.getAnimationType() == AnimationType.DROP) {
+ coordinate = radius * 3;
+ } else {
+ coordinate = radius;
+ }
+
+ return coordinate;
+ }
+
+ public static Pair getProgress(@NonNull Indicator indicator, int position, float positionOffset, boolean isRtl) {
+ int count = indicator.getCount();
+ int selectedPosition = indicator.getSelectedPosition();
+
+ if (isRtl) {
+ position = (count - 1) - position;
+ }
+
+ if (position < 0) {
+ position = 0;
+
+ } else if (position > count - 1) {
+ position = count - 1;
+ }
+
+ boolean isRightOverScrolled = position > selectedPosition;
+ boolean isLeftOverScrolled;
+
+ if (isRtl) {
+ isLeftOverScrolled = position - 1 < selectedPosition;
+ } else {
+ isLeftOverScrolled = position + 1 < selectedPosition;
+ }
+
+ if (isRightOverScrolled || isLeftOverScrolled) {
+ selectedPosition = position;
+ indicator.setSelectedPosition(selectedPosition);
+ }
+
+ boolean slideToRightSide = selectedPosition == position && positionOffset != 0;
+ int selectingPosition;
+ float selectingProgress;
+
+ if (slideToRightSide) {
+ selectingPosition = isRtl ? position - 1 : position + 1;
+ selectingProgress = positionOffset;
+
+ } else {
+ selectingPosition = position;
+ selectingProgress = 1 - positionOffset;
+ }
+
+ if (selectingProgress > 1) {
+ selectingProgress = 1;
+
+ } else if (selectingProgress < 0) {
+ selectingProgress = 0;
+ }
+
+ return new Pair<>(selectingPosition, selectingProgress);
+ }
+}
diff --git a/pageindicatorview/src/main/java/com/rd/utils/DensityUtils.java b/pageindicatorview/src/main/java/com/rd/utils/DensityUtils.java
new file mode 100644
index 0000000..b35e1de
--- /dev/null
+++ b/pageindicatorview/src/main/java/com/rd/utils/DensityUtils.java
@@ -0,0 +1,15 @@
+package com.rd.utils;
+
+import android.content.res.Resources;
+import android.util.TypedValue;
+
+public class DensityUtils {
+
+ public static int dpToPx(int dp) {
+ return (int) TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, dp, Resources.getSystem().getDisplayMetrics());
+ }
+
+ public static int pxToDp(float px) {
+ return (int) TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_PX, px, Resources.getSystem().getDisplayMetrics());
+ }
+}
diff --git a/pageindicatorview/src/main/java/com/rd/utils/IdUtils.java b/pageindicatorview/src/main/java/com/rd/utils/IdUtils.java
new file mode 100644
index 0000000..09e037e
--- /dev/null
+++ b/pageindicatorview/src/main/java/com/rd/utils/IdUtils.java
@@ -0,0 +1,37 @@
+package com.rd.utils;
+
+import android.os.Build;
+import android.view.View;
+
+import java.util.concurrent.atomic.AtomicInteger;
+
+public class IdUtils {
+
+ private static final AtomicInteger nextGeneratedId = new AtomicInteger(1);
+
+ public static int generateViewId(){
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.JELLY_BEAN_MR1) {
+ return generateId();
+ } else {
+ return View.generateViewId();
+ }
+ }
+
+ /**
+ * Generate a value suitable for use in #setId(int).
+ * This value will not collide with ID values generated at build time by aapt for R.id.
+ *
+ * @return a generated ID value
+ */
+ private static int generateId() {
+ for (; ; ) {
+ final int result = nextGeneratedId.get();
+ // aapt-generated IDs have the high byte nonzero; clamp to the range under that.
+ int newValue = result + 1;
+ if (newValue > 0x00FFFFFF) newValue = 1; // Roll over to 1, not 0.
+ if (nextGeneratedId.compareAndSet(result, newValue)) {
+ return result;
+ }
+ }
+ }
+}
diff --git a/pageindicatorview/src/main/res/values/attrs.xml b/pageindicatorview/src/main/res/values/attrs.xml
new file mode 100644
index 0000000..9c7fce4
--- /dev/null
+++ b/pageindicatorview/src/main/res/values/attrs.xml
@@ -0,0 +1,49 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/pageindicatorview/src/test/java/com/rd/pageindicatorview/ExampleUnitTest.kt b/pageindicatorview/src/test/java/com/rd/pageindicatorview/ExampleUnitTest.kt
new file mode 100644
index 0000000..54fa3eb
--- /dev/null
+++ b/pageindicatorview/src/test/java/com/rd/pageindicatorview/ExampleUnitTest.kt
@@ -0,0 +1,17 @@
+package com.rd.pageindicatorview
+
+import org.junit.Test
+
+import org.junit.Assert.*
+
+/**
+ * Example local unit test, which will execute on the development machine (host).
+ *
+ * See [testing documentation](http://d.android.com/tools/testing).
+ */
+class ExampleUnitTest {
+ @Test
+ fun addition_isCorrect() {
+ assertEquals(4, 2 + 2)
+ }
+}
\ No newline at end of file
diff --git a/settings.gradle b/settings.gradle
index d9f9c9a..578791f 100644
--- a/settings.gradle
+++ b/settings.gradle
@@ -7,12 +7,17 @@ pluginManagement {
}
}
dependencyResolutionManagement {
- repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
+ repositoriesMode.set(RepositoriesMode.PREFER_SETTINGS)
repositories {
google()
jcenter()
mavenCentral()
+ maven { url "https://jitpack.io" }
+
+
}
}
rootProject.name = "InstanceDownload"
-include ':app'
\ No newline at end of file
+include ':app'
+include ':cropper'
+include ':pageindicatorview'