| 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(); |
| } |
| } |
| } |
| } |
| } |