aboutsummaryrefslogtreecommitdiff
path: root/WordPress/src/main/java/org/wordpress/android/widgets/WPNetworkImageView.java
diff options
context:
space:
mode:
authorChris Warrington <cmw@google.com>2016-10-18 12:29:21 +0100
committerChris Warrington <cmw@google.com>2016-10-18 12:34:18 +0100
commite3780081075c01aa1dff6d1f373cb43192b33e68 (patch)
treefb734615933a39f3d009210dc0d1457160479b35 /WordPress/src/main/java/org/wordpress/android/widgets/WPNetworkImageView.java
parent7e05eb7e57827eddc885570bc00aed8a50320dbf (diff)
parent025b8b226c8d8edba2b309ca878572f40512eca7 (diff)
downloadgradle-perf-android-medium-e3780081075c01aa1dff6d1f373cb43192b33e68.tar.gz
Change-Id: I63f5e16d09297c48432192761b840310935eb903
Diffstat (limited to 'WordPress/src/main/java/org/wordpress/android/widgets/WPNetworkImageView.java')
-rw-r--r--WordPress/src/main/java/org/wordpress/android/widgets/WPNetworkImageView.java449
1 files changed, 449 insertions, 0 deletions
diff --git a/WordPress/src/main/java/org/wordpress/android/widgets/WPNetworkImageView.java b/WordPress/src/main/java/org/wordpress/android/widgets/WPNetworkImageView.java
new file mode 100644
index 000000000..1f858664c
--- /dev/null
+++ b/WordPress/src/main/java/org/wordpress/android/widgets/WPNetworkImageView.java
@@ -0,0 +1,449 @@
+package org.wordpress.android.widgets;
+
+import android.animation.ObjectAnimator;
+import android.content.Context;
+import android.graphics.Bitmap;
+import android.graphics.BitmapFactory;
+import android.graphics.Color;
+import android.graphics.drawable.ColorDrawable;
+import android.os.AsyncTask;
+import android.support.annotation.ColorRes;
+import android.support.annotation.DrawableRes;
+import android.support.v4.content.ContextCompat;
+import android.support.v7.widget.AppCompatImageView;
+import android.text.TextUtils;
+import android.util.AttributeSet;
+import android.view.View;
+import android.view.ViewGroup.LayoutParams;
+
+import com.android.volley.VolleyError;
+import com.android.volley.toolbox.ImageLoader;
+
+import org.wordpress.android.R;
+import org.wordpress.android.WordPress;
+import org.wordpress.android.datasets.ReaderThumbnailTable;
+import org.wordpress.android.ui.reader.utils.ReaderVideoUtils;
+import org.wordpress.android.util.AppLog;
+import org.wordpress.android.util.DisplayUtils;
+import org.wordpress.android.util.ImageUtils;
+import org.wordpress.android.util.MediaUtils;
+import org.wordpress.android.util.VolleyUtils;
+
+import java.util.HashSet;
+
+/**
+ * most of the code below is from Volley's NetworkImageView, but it's modified to support:
+ * (1) fading in downloaded images
+ * (2) manipulating images before display
+ * (3) automatically retrieving the thumbnail for YouTube & Vimeo videos
+ */
+public class WPNetworkImageView extends AppCompatImageView {
+ public enum ImageType {
+ NONE,
+ PHOTO,
+ PHOTO_ROUNDED,
+ VIDEO,
+ AVATAR,
+ BLAVATAR,
+ GONE_UNTIL_AVAILABLE,
+ }
+
+ public interface ImageLoadListener {
+ void onLoaded();
+ void onError();
+ }
+
+ private ImageType mImageType = ImageType.NONE;
+ private String mUrl;
+ private ImageLoader.ImageContainer mImageContainer;
+
+ private int mDefaultImageResId;
+ private int mErrorImageResId;
+
+ private static final HashSet<String> mUrlSkipList = new HashSet<>();
+
+ public WPNetworkImageView(Context context) {
+ super(context);
+ }
+ public WPNetworkImageView(Context context, AttributeSet attrs) {
+ super(context, attrs);
+ }
+ public WPNetworkImageView(Context context, AttributeSet attrs, int defStyle) {
+ super(context, attrs, defStyle);
+ }
+
+ public void setImageUrl(String url, ImageType imageType) {
+ setImageUrl(url, imageType, null);
+ }
+
+ public void setImageUrl(String url, ImageType imageType, ImageLoadListener imageLoadListener) {
+ mUrl = url;
+ mImageType = imageType;
+
+ // The URL has potentially changed. See if we need to load it.
+ loadImageIfNecessary(false, imageLoadListener);
+ }
+
+ /*
+ * determine whether we can show a thumbnail image for the passed video - currently
+ * we support YouTube, Vimeo & standard images
+ */
+ public static boolean canShowVideoThumbnail(String videoUrl) {
+ return ReaderVideoUtils.isVimeoLink(videoUrl)
+ || ReaderVideoUtils.isYouTubeVideoLink(videoUrl)
+ || MediaUtils.isValidImage(videoUrl);
+ }
+
+ /*
+ * retrieves and displays the thumbnail for the passed video
+ */
+ public void setVideoUrl(final long postId, final String videoUrl) {
+ mImageType = ImageType.VIDEO;
+
+ if (TextUtils.isEmpty(videoUrl)) {
+ showErrorImage();
+ return;
+ }
+
+ // if this is a YouTube video we can determine the thumbnail url from the passed url,
+ // otherwise check if we've already cached the thumbnail url for this video
+ String thumbnailUrl;
+ if (ReaderVideoUtils.isYouTubeVideoLink(videoUrl)) {
+ thumbnailUrl = ReaderVideoUtils.getYouTubeThumbnailUrl(videoUrl);
+ } else {
+ thumbnailUrl = ReaderThumbnailTable.getThumbnailUrl(videoUrl);
+ }
+ if (!TextUtils.isEmpty(thumbnailUrl)) {
+ setImageUrl(thumbnailUrl, ImageType.VIDEO);
+ return;
+ }
+
+ if (MediaUtils.isValidImage(videoUrl)) {
+ setImageUrl(videoUrl, ImageType.VIDEO);
+ } else if (ReaderVideoUtils.isVimeoLink(videoUrl)) {
+ // vimeo videos require network request to get thumbnail
+ showDefaultImage();
+ ReaderVideoUtils.requestVimeoThumbnail(videoUrl, new ReaderVideoUtils.VideoThumbnailListener() {
+ @Override
+ public void onResponse(boolean successful, String thumbnailUrl) {
+ if (successful) {
+ ReaderThumbnailTable.addThumbnail(postId, videoUrl, thumbnailUrl);
+ setImageUrl(thumbnailUrl, ImageType.VIDEO);
+ }
+ }
+ });
+ } else {
+ AppLog.d(AppLog.T.UTILS, "no video thumbnail for " + videoUrl);
+ showErrorImage();
+ }
+ }
+
+ /**
+ * Loads the image for the view if it isn't already loaded.
+ * @param isInLayoutPass True if this was invoked from a layout pass, false otherwise.
+ */
+ private void loadImageIfNecessary(final boolean isInLayoutPass, final ImageLoadListener imageLoadListener) {
+ // do nothing if image type hasn't been set yet
+ if (mImageType == ImageType.NONE) {
+ return;
+ }
+
+ int width = getWidth();
+ int height = getHeight();
+ ScaleType scaleType = getScaleType();
+
+ boolean wrapWidth = false, wrapHeight = false;
+ if (getLayoutParams() != null) {
+ wrapWidth = getLayoutParams().width == LayoutParams.WRAP_CONTENT;
+ wrapHeight = getLayoutParams().height == LayoutParams.WRAP_CONTENT;
+ }
+
+ // if the view's bounds aren't known yet, and this is not a wrap-content/wrap-content
+ // view, hold off on loading the image.
+ boolean isFullyWrapContent = wrapWidth && wrapHeight;
+ if (width == 0 && height == 0 && !isFullyWrapContent && mImageType != ImageType.GONE_UNTIL_AVAILABLE) {
+ return;
+ }
+
+ // if the URL to be loaded in this view is empty, cancel any old requests and clear the
+ // currently loaded image.
+ if (TextUtils.isEmpty(mUrl)) {
+ if (mImageContainer != null) {
+ mImageContainer.cancelRequest();
+ mImageContainer = null;
+ }
+ showErrorImage();
+ return;
+ }
+
+ // if there was an old request in this view, check if it needs to be canceled.
+ if (mImageContainer != null && mImageContainer.getRequestUrl() != null) {
+ if (mImageContainer.getRequestUrl().equals(mUrl)) {
+ // if the request is from the same URL and it's not GONE_UNTIL_AVAILABLE, return.
+ if (mImageType != ImageType.GONE_UNTIL_AVAILABLE) {
+ // GONE_UNTIL_AVAILABLE image type will make a new request if the previous response wasn't a 404 response,
+ // Volley usually returns it from cache.
+ return;
+ }
+ } else {
+ // if there is a pre-existing request, cancel it if it's fetching a different URL.
+ mImageContainer.cancelRequest();
+ showDefaultImage();
+ }
+ }
+
+ // skip this URL if a previous request for it returned a 404
+ if (mUrlSkipList.contains(mUrl)) {
+ AppLog.d(AppLog.T.UTILS, "skipping image request " + mUrl);
+ showErrorImage();
+ return;
+ }
+
+ // Calculate the max image width / height to use while ignoring WRAP_CONTENT dimens.
+ int maxWidth = wrapWidth ? 0 : width;
+ int maxHeight = wrapHeight ? 0 : height;
+
+ // The pre-existing content of this view didn't match the current URL. Load the new image
+ // from the network.
+ ImageLoader.ImageContainer newContainer = WordPress.imageLoader.get(mUrl,
+ new ImageLoader.ImageListener() {
+ @Override
+ public void onErrorResponse(VolleyError error) {
+ showErrorImage();
+ // keep track of URLs that 404 so we can skip them the next time
+ int statusCode = VolleyUtils.statusCodeFromVolleyError(error);
+ if (statusCode == 404) {
+ mUrlSkipList.add(mUrl);
+ }
+
+ if (imageLoadListener != null) {
+ imageLoadListener.onError();
+ }
+ }
+
+ @Override
+ public void onResponse(final ImageLoader.ImageContainer response, boolean isImmediate) {
+ // If this was an immediate response that was delivered inside of a layout
+ // pass do not set the image immediately as it will trigger a requestLayout
+ // inside of a layout. Instead, defer setting the image by posting back to
+ // the main thread.
+ if (isImmediate && isInLayoutPass) {
+ post(new Runnable() {
+ @Override
+ public void run() {
+ handleResponse(response, true, imageLoadListener);
+ }
+ });
+ } else {
+ handleResponse(response, isImmediate, imageLoadListener);
+ }
+ }
+ }, maxWidth, maxHeight, scaleType);
+
+ // update the ImageContainer to be the new bitmap container.
+ mImageContainer = newContainer;
+ }
+
+ private static boolean canFadeInImageType(ImageType imageType) {
+ return imageType == ImageType.PHOTO
+ || imageType == ImageType.VIDEO;
+ }
+
+ private void handleResponse(ImageLoader.ImageContainer response, boolean isCached, ImageLoadListener
+ imageLoadListener) {
+ if (response.getBitmap() != null) {
+ Bitmap bitmap = response.getBitmap();
+
+ if (mImageType == ImageType.GONE_UNTIL_AVAILABLE) {
+ setVisibility(View.VISIBLE);
+ }
+
+ // Apply circular rounding to avatars in a background task
+ if (mImageType == ImageType.AVATAR) {
+ new ShapeBitmapTask(ShapeType.CIRCLE, imageLoadListener).executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR, bitmap);
+ return;
+ } else if (mImageType == ImageType.PHOTO_ROUNDED) {
+ new ShapeBitmapTask(ShapeType.ROUNDED, imageLoadListener).executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR, bitmap);
+ return;
+ }
+
+ setImageBitmap(bitmap);
+
+ // fade in photos/videos if not cached (not used for other image types since animation can be expensive)
+ if (!isCached && canFadeInImageType(mImageType)) {
+ fadeIn();
+ }
+ } else {
+ showDefaultImage();
+ }
+ }
+
+ public void invalidateImage() {
+ mUrlSkipList.clear();
+
+ if (mImageContainer != null) {
+ // If the view was bound to an image request, cancel it and clear
+ // out the image from the view.
+ mImageContainer.cancelRequest();
+ setImageBitmap(null);
+ // also clear out the container so we can reload the image if necessary.
+ mImageContainer = null;
+ }
+ }
+
+ @Override
+ protected void onLayout(boolean changed, int left, int top, int right, int bottom) {
+ super.onLayout(changed, left, top, right, bottom);
+ if (!isInEditMode()) {
+ loadImageIfNecessary(true, null);
+ }
+ }
+
+ @Override
+ protected void onDetachedFromWindow() {
+ invalidateImage();
+
+ super.onDetachedFromWindow();
+ }
+
+ @Override
+ protected void drawableStateChanged() {
+ super.drawableStateChanged();
+ invalidate();
+ }
+
+ private int getColorRes(@ColorRes int resId) {
+ return ContextCompat.getColor(getContext(), resId);
+ }
+
+ public void setDefaultImageResId(@DrawableRes int resourceId) {
+ mDefaultImageResId = resourceId;
+ }
+
+ public void setErrorImageResId(@DrawableRes int resourceId) {
+ mErrorImageResId = resourceId;
+ }
+
+ public void showDefaultImage() {
+ // use default image resource if one was supplied...
+ if (mDefaultImageResId != 0) {
+ setImageResource(mDefaultImageResId);
+ return;
+ }
+
+ // ... otherwise use built-in default
+ switch (mImageType) {
+ case GONE_UNTIL_AVAILABLE:
+ this.setVisibility(View.GONE);
+ break;
+ case NONE:
+ // do nothing
+ break;
+ case AVATAR:
+ // Grey circle for avatars
+ setImageResource(R.drawable.shape_oval_grey_light);
+ break;
+ default :
+ // light grey box for all others
+ setImageDrawable(new ColorDrawable(getColorRes(R.color.grey_light)));
+ break;
+ }
+ }
+
+ private void showErrorImage() {
+ if (mErrorImageResId != 0) {
+ setImageResource(mErrorImageResId);
+ return;
+ }
+
+ switch (mImageType) {
+ case GONE_UNTIL_AVAILABLE:
+ this.setVisibility(View.GONE);
+ break;
+ case NONE:
+ // do nothing
+ break;
+ case AVATAR:
+ // circular "mystery man" for failed avatars
+ showDefaultGravatarImage();
+ break;
+ case BLAVATAR:
+ showDefaultBlavatarImage();
+ break;
+ default :
+ // grey box for all others
+ setImageDrawable(new ColorDrawable(getColorRes(R.color.grey_lighten_30)));
+ break;
+ }
+ }
+
+ public void showDefaultGravatarImage() {
+ if (getContext() == null) return;
+ new ShapeBitmapTask(ShapeType.CIRCLE, null).executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR, BitmapFactory.decodeResource(
+ getContext().getResources(),
+ R.drawable.gravatar_placeholder
+ ));
+ }
+
+ public void showDefaultBlavatarImage() {
+ setImageResource(R.drawable.blavatar_placeholder);
+ }
+
+ // --------------------------------------------------------------------------------------------------
+
+
+ private static final int FADE_TRANSITION = 250;
+
+ private void fadeIn() {
+ ObjectAnimator alpha = ObjectAnimator.ofFloat(this, View.ALPHA, 0.25f, 1f);
+ alpha.setDuration(FADE_TRANSITION);
+ alpha.start();
+ }
+
+ // Circularizes or rounds the corners of a bitmap in a background thread
+ private enum ShapeType { CIRCLE, ROUNDED }
+ private class ShapeBitmapTask extends AsyncTask<Bitmap, Void, Bitmap> {
+ private final ImageLoadListener mImageLoadListener;
+ private final ShapeType mShapeType;
+ private int mRoundedCornerRadiusPx;
+ private static final int ROUNDED_CORNER_RADIUS_DP = 2;
+
+ public ShapeBitmapTask(ShapeType shapeType, ImageLoadListener imageLoadListener) {
+ mImageLoadListener = imageLoadListener;
+ mShapeType = shapeType;
+ if (mShapeType == ShapeType.ROUNDED) {
+ mRoundedCornerRadiusPx = DisplayUtils.dpToPx(getContext(), ROUNDED_CORNER_RADIUS_DP);
+ }
+ }
+
+ @Override
+ protected Bitmap doInBackground(Bitmap... params) {
+ if (params == null || params.length == 0) return null;
+
+ Bitmap bitmap = params[0];
+ switch (mShapeType) {
+ case CIRCLE:
+ return ImageUtils.getCircularBitmap(bitmap);
+ case ROUNDED:
+ return ImageUtils.getRoundedEdgeBitmap(bitmap, mRoundedCornerRadiusPx, Color.TRANSPARENT);
+ default:
+ return bitmap;
+ }
+ }
+
+ @Override
+ protected void onPostExecute(Bitmap bitmap) {
+ if (bitmap != null) {
+ setImageBitmap(bitmap);
+ if (mImageLoadListener != null) {
+ mImageLoadListener.onLoaded();
+ fadeIn();
+ }
+ } else {
+ if (mImageLoadListener != null) {
+ mImageLoadListener.onError();
+ }
+ }
+ }
+ }
+}