diff options
author | Chris Warrington <cmw@google.com> | 2016-10-18 12:29:21 +0100 |
---|---|---|
committer | Chris Warrington <cmw@google.com> | 2016-10-18 12:34:18 +0100 |
commit | e3780081075c01aa1dff6d1f373cb43192b33e68 (patch) | |
tree | fb734615933a39f3d009210dc0d1457160479b35 /WordPress/src/main/java/org/wordpress/android/widgets/WPNetworkImageView.java | |
parent | 7e05eb7e57827eddc885570bc00aed8a50320dbf (diff) | |
parent | 025b8b226c8d8edba2b309ca878572f40512eca7 (diff) | |
download | gradle-perf-android-medium-e3780081075c01aa1dff6d1f373cb43192b33e68.tar.gz |
Merge remote-tracking branch 'origin/upstream-master' into masterHEADstudio-3.4.0studio-3.2.1studio-3.1.2studio-3.0studio-2.3gradle_3.4.0gradle_3.1.2gradle_3.0.0gradle_2.3.0studio-master-devmirror-goog-studio-master-devmastermain
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.java | 449 |
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(); + } + } + } + } +} |